diff --git a/.eslintrc.json b/.eslintrc.json new file mode 100644 index 000000000..09d13e222 --- /dev/null +++ b/.eslintrc.json @@ -0,0 +1,63 @@ +{ + "parser": "@typescript-eslint/parser", + "env": { + "node": true, + "browser": true, + "commonjs": true, + "es2021": true, + "mocha": true + }, + "extends": [ + "eslint:recommended", + "plugin:@typescript-eslint/recommended", + "plugin:react/recommended", + "google", + "prettier", + "plugin:json/recommended" + ], + "overrides": [ + { + "files": ["test/**/*.js", "**/*.json", "cypress/**/*.js", "plugins/**/*.js"], + "parserOptions": { + "project": null + }, + "parser": "espree", + "env": { + "cypress/globals": true + }, + "plugins": ["cypress"], + "rules": { + "@typescript-eslint/no-unused-expressions": "off" + } + } + ], + "parserOptions": { + "project": "./tsconfig.json", + "requireConfigFile": false, + "ecmaVersion": 12, + "sourceType": "module", + "ecmaFeatures": { + "jsx": true, + "modules": true + }, + "babelOptions": { + "presets": ["@babel/preset-react"] + } + }, + "plugins": ["@typescript-eslint", "react", "prettier"], + "rules": { + "react/prop-types": "off", + "require-jsdoc": "off", + "no-async-promise-executor": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": "off", + "@typescript-eslint/no-require-imports": "off", + "@typescript-eslint/no-unused-expressions": "off" + }, + "settings": { + "react": { + "version": "detect" + } + }, + "ignorePatterns": ["src/config/generated/config.ts"] +} diff --git a/cypress/e2e/tagPush.cy.js b/cypress/e2e/tagPush.cy.js new file mode 100644 index 000000000..a6fd9b54f --- /dev/null +++ b/cypress/e2e/tagPush.cy.js @@ -0,0 +1,131 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +describe('Tag Push Functionality', () => { + beforeEach(() => { + cy.login('admin', 'admin'); + cy.on('uncaught:exception', () => false); + + // Create test data for tag pushes + cy.createTestTagPush(); + }); + + describe('Tag Push Display in PushesTable', () => { + it('can navigate to push dashboard and view push table', () => { + cy.visit('/dashboard/push'); + + // Wait for API call to complete + cy.wait('@getPushes'); + + // Check that we can see the basic table structure + cy.get('table', { timeout: 10000 }).should('exist'); + cy.get('thead').should('exist'); + cy.get('tbody').should('exist'); + + // Now we should have test data, so we can check for rows + cy.get('tbody tr').should('have.length.at.least', 1); + + // Check the structure of the first row + cy.get('tbody tr') + .first() + .within(() => { + cy.get('td').should('have.length.at.least', 6); // We know there are multiple columns + // Check for tag-specific content + cy.contains('v1.0.0').should('exist'); // Tag name + cy.contains('test-tagger').should('exist'); // Tagger + }); + }); + + it('can interact with push table entries', () => { + cy.visit('/dashboard/push'); + cy.wait('@getPushes'); + + cy.get('tbody tr').should('have.length.at.least', 1); + + // Check for clickable elements in the first row + cy.get('tbody tr') + .first() + .within(() => { + // Should have links and buttons + cy.get('a').should('have.length.at.least', 1); // Repository links, etc. + cy.get('button').should('have.length.at.least', 1); // Action button + }); + }); + }); + + describe('Tag Push Details Page', () => { + it('can access push details page structure', () => { + // Try to access a push details page directly + cy.visit('/dashboard/push/test-push-id', { failOnStatusCode: false }); + + // Check basic page structure exists (regardless of whether push exists) + cy.get('body').should('exist'); // Basic content check + + // If we end up redirected, that's also acceptable behavior + cy.url().should('include', '/dashboard'); + }); + }); + + describe('Basic UI Navigation', () => { + it('can navigate between dashboard pages', () => { + cy.visit('/dashboard/push'); + cy.wait('@getPushes'); + cy.get('table', { timeout: 10000 }).should('exist'); + + // Test navigation to repo dashboard + cy.visit('/dashboard/repo'); + cy.get('table', { timeout: 10000 }).should('exist'); + + // Test navigation to user management if it exists + cy.visit('/dashboard/user'); + cy.get('body').should('exist'); + }); + }); + + describe('Application Robustness', () => { + it('handles navigation to non-existent push gracefully', () => { + // Try to visit a non-existent push detail page + cy.visit('/dashboard/push/non-existent-push-id', { failOnStatusCode: false }); + + // Should either redirect or show error page, but not crash + cy.get('body').should('exist'); + }); + + it('maintains functionality after page refresh', () => { + cy.visit('/dashboard/push'); + cy.wait('@getPushes'); + cy.get('table', { timeout: 10000 }).should('exist'); + + // Refresh the page + cy.reload(); + // Wait for API call again after reload + cy.wait('@getPushes'); + + // Wait for page to reload and check basic functionality + cy.get('body').should('exist'); + + // Give more time for table to load after refresh, or check if redirected + cy.url().then((url) => { + if (url.includes('/dashboard/push')) { + cy.get('table', { timeout: 15000 }).should('exist'); + } else { + // If redirected (e.g., to login), that's also acceptable behavior + cy.get('body').should('exist'); + } + }); + }); + }); +}); diff --git a/cypress/support/commands.js b/cypress/support/commands.js index e852c0a28..c90f5a4a4 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -94,6 +94,69 @@ Cypress.Commands.add('getCSRFToken', () => { }); }); +Cypress.Commands.add('createTestTagPush', (pushData = {}) => { + const defaultTagPush = { + id: `test-tag-push-${Date.now()}`, + steps: [], + error: false, + blocked: true, + allowPush: false, + authorised: false, + canceled: false, + rejected: false, + autoApproved: false, + autoRejected: false, + type: 'push', + method: 'get', + timestamp: Date.now(), + project: 'cypress-test', + repoName: 'test-repo.git', + url: 'https://github.com/cypress-test/test-repo.git', + repo: 'cypress-test/test-repo.git', + user: 'test-tagger', + userEmail: 'test-tagger@test.com', + branch: 'refs/heads/main', + tags: ['refs/tags/v1.0.0'], + commitFrom: '0000000000000000000000000000000000000000', + commitTo: 'abcdef1234567890abcdef1234567890abcdef12', + lastStep: null, + blockedMessage: '\n\n\nGitProxy has received your tag push\n\n\n', + _id: null, + attestation: null, + tagData: [ + { + tagName: 'v1.0.0', + type: 'annotated', + tagger: 'test-tagger', + message: 'Release version 1.0.0\n\nThis is a test tag release for Cypress testing.', + timestamp: Math.floor(Date.now() / 1000), + }, + ], + commitData: [ + { + commitTs: Math.floor(Date.now() / 1000) - 300, + commitTimestamp: Math.floor(Date.now() / 1000) - 300, + message: 'feat: add new tag push feature', + committer: 'test-committer', + author: 'test-author', + authorEmail: 'test-author@test.com', + }, + ], + diff: { + content: '+++ test tag push implementation', + }, + ...pushData, + }; + + // For now, intercept the push API calls and return our test data + cy.intercept('GET', '**/api/v1/push*', { + statusCode: 200, + body: [defaultTagPush], + }).as('getPushes'); + + return cy.wrap(defaultTagPush); +}); + Cypress.Commands.add('createUser', (username, password, email, gitAccount) => { cy.request({ method: 'POST', diff --git a/src/db/mongo/pushes.ts b/src/db/mongo/pushes.ts index 91893cdb8..c45639d31 100644 --- a/src/db/mongo/pushes.ts +++ b/src/db/mongo/pushes.ts @@ -52,9 +52,12 @@ export const getPushes = async ( rejected: 1, repo: 1, repoName: 1, + tag: 1, + tagData: 1, timestamp: 1, type: 1, url: 1, + user: 1, }, sort: { timestamp: -1 }, }); diff --git a/src/db/types.ts b/src/db/types.ts index a77838300..335e19c9d 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -108,6 +108,31 @@ export class User { } } +export type Push = { + id: string; + allowPush: boolean; + authorised: boolean; + blocked: boolean; + blockedMessage: string; + branch: string; + canceled: boolean; + commitData: object; + commitFrom: string; + commitTo: string; + error: boolean; + method: string; + project: string; + rejected: boolean; + repo: string; + repoName: string; + tag?: string; + tagData?: object; + timepstamp: string; + type: string; + url: string; + user?: string; +}; + export interface PublicUser { username: string; displayName: string; diff --git a/src/proxy/actions/Action.ts b/src/proxy/actions/Action.ts index 0faff8058..d40507a38 100644 --- a/src/proxy/actions/Action.ts +++ b/src/proxy/actions/Action.ts @@ -17,13 +17,31 @@ import { processGitURLForNameAndOrg, processUrlPath } from '../routes/helper'; import { Step } from './Step'; import { CompletedAttestation, CommitData, Rejection } from '../processors/types'; +import { TagData } from '../../types/models'; + +export enum RequestType { + PUSH = 'push', + + PULL = 'pull', + + DEFAULT = 'default', +} + +export enum PushType { + /** Push to a tag ref (refs/tags/*) */ + TAG = 'tag', + + /** Push to a branch ref (refs/heads/*) or any other non-tag ref */ + BRANCH = 'branch', +} /** * Class representing a Push. */ class Action { id: string; - type: string; + type: RequestType; + actionType?: PushType; method: string; timestamp: number; project: string; @@ -53,6 +71,8 @@ class Action { rejection?: Rejection; lastStep?: Step; proxyGitPath?: string; + tags?: string[]; + tagData?: TagData[]; newIdxFiles?: string[]; protocol?: 'https' | 'ssh'; pullAuthStrategy?: @@ -70,7 +90,7 @@ class Action { * @param {number} timestamp The timestamp of the action * @param {string} url The URL to the repo that should be proxied (with protocol, origin, repo path, but not the path for the git operation). */ - constructor(id: string, type: string, method: string, timestamp: number, url: string) { + constructor(id: string, type: RequestType, method: string, timestamp: number, url: string) { this.id = id; this.type = type; this.method = method; diff --git a/src/proxy/actions/index.ts b/src/proxy/actions/index.ts index 0851e5a76..5ca33f87a 100644 --- a/src/proxy/actions/index.ts +++ b/src/proxy/actions/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Action } from './Action'; +import { Action, RequestType, PushType } from './Action'; import { Step } from './Step'; -export { Action, Step }; +export { Action, Step, RequestType, PushType }; diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index ab63f1f8d..ba58b3f4c 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -17,16 +17,16 @@ import { Request, Response } from 'express'; import { PluginLoader } from '../plugin'; -import { Action } from './actions'; +import { Action, RequestType, PushType } from './actions'; import * as proc from './processors'; +import { Processor } from './processors/types'; import { attemptAutoApproval, attemptAutoRejection } from './actions/autoActions'; import { handleErrorAndLog } from '../utils/errors'; -const pushActionChain: ((req: Request, action: Action) => Promise)[] = [ - proc.push.parsePush, +const branchPushChain: Processor['exec'][] = [ proc.push.checkEmptyBranch, proc.push.checkRepoInAuthorisedList, - proc.push.checkCommitMessages, + proc.push.checkMessages, proc.push.checkAuthorEmails, proc.push.checkUserPushPermission, proc.push.pullRemote, // cleanup is handled after chain execution if successful @@ -40,13 +40,20 @@ const pushActionChain: ((req: Request, action: Action) => Promise)[] = [ proc.push.blockForAuth, ]; -const pullActionChain: ((req: Request, action: Action) => Promise)[] = [ +const tagPushChain: Processor['exec'][] = [ proc.push.checkRepoInAuthorisedList, + proc.push.checkUserPushPermission, + proc.push.checkIfWaitingAuth, + proc.push.checkMessages, + proc.push.pullRemote, + proc.push.writePack, + proc.push.preReceive, + proc.push.blockForAuth, ]; -const defaultActionChain: ((req: Request, action: Action) => Promise)[] = [ - proc.push.checkRepoInAuthorisedList, -]; +const pullActionChain: Processor['exec'][] = [proc.push.checkRepoInAuthorisedList]; + +const defaultActionChain: Processor['exec'][] = [proc.push.checkRepoInAuthorisedList]; let pluginsInserted = false; @@ -55,9 +62,16 @@ export const executeChain = async (req: Request, _res: Response): Promise Promise)[]> => { +export const getChain = async (action: Action): Promise => { if (chainPluginLoader === undefined) { console.error( 'Plugin loader was not initialized! This is an application error. Please report it to the GitProxy maintainers. Skipping plugins...', ); pluginsInserted = true; } + if (!pluginsInserted) { console.log( `Inserting loaded plugins (${chainPluginLoader.pushPlugins.length} push, ${chainPluginLoader.pullPlugins.length} pull) into proxy chains`, ); for (const pluginObj of chainPluginLoader.pushPlugins) { console.log(`Inserting push plugin ${pluginObj.constructor.name} into chain`); - // insert custom functions after parsePush but before other actions - pushActionChain.splice(1, 0, pluginObj.exec); + branchPushChain.splice(0, 0, pluginObj.exec); + tagPushChain.splice(0, 0, pluginObj.exec); } for (const pluginObj of chainPluginLoader.pullPlugins) { console.log(`Inserting pull plugin ${pluginObj.constructor.name} into chain`); @@ -122,12 +135,14 @@ export const getChain = async ( // This is set to true so that we don't re-insert the plugins into the chain pluginsInserted = true; } - if (action.type === 'pull') { - return pullActionChain; - } else if (action.type === 'push') { - return pushActionChain; - } else { - return defaultActionChain; + + switch (action.type) { + case RequestType.PULL: + return pullActionChain; + case RequestType.PUSH: + return action.actionType === PushType.TAG ? tagPushChain : branchPushChain; + default: + return defaultActionChain; } }; @@ -141,8 +156,11 @@ export default { get pluginsInserted() { return pluginsInserted; }, - get pushActionChain() { - return pushActionChain; + get branchPushChain() { + return branchPushChain; + }, + get tagPushChain() { + return tagPushChain; }, get pullActionChain() { return pullActionChain; diff --git a/src/proxy/processors/constants.ts b/src/proxy/processors/constants.ts index b6d369ac0..f5b7f0c90 100644 --- a/src/proxy/processors/constants.ts +++ b/src/proxy/processors/constants.ts @@ -16,12 +16,15 @@ import { CommitData } from './types'; +export const REFS_PREFIX = 'refs/'; export const BRANCH_PREFIX = 'refs/heads/'; +export const TAG_PREFIX = 'refs/tags/'; export const EMPTY_COMMIT_HASH = '0000000000000000000000000000000000000000'; export const FLUSH_PACKET = '0000'; export const PACK_SIGNATURE = 'PACK'; export const PACKET_SIZE = 4; export const GIT_OBJECT_TYPE_COMMIT = 1; +export const GIT_OBJECT_TYPE_TAG = 4; export const SAMPLE_COMMIT: CommitData = { tree: '1234567890', diff --git a/src/proxy/processors/post-processor/audit.ts b/src/proxy/processors/post-processor/audit.ts index 3d72116c8..31c52b5c7 100644 --- a/src/proxy/processors/post-processor/audit.ts +++ b/src/proxy/processors/post-processor/audit.ts @@ -17,10 +17,10 @@ import { Request } from 'express'; import { writeAudit } from '../../../db'; -import { Action } from '../../actions'; +import { Action, RequestType } from '../../actions'; const exec = async (_req: Request, action: Action) => { - if (action.type !== 'pull') { + if (action.type !== RequestType.PULL) { await writeAudit(action); } diff --git a/src/proxy/processors/pre-processor/index.ts b/src/proxy/processors/pre-processor/index.ts index f2516a1d4..75383e235 100644 --- a/src/proxy/processors/pre-processor/index.ts +++ b/src/proxy/processors/pre-processor/index.ts @@ -15,5 +15,7 @@ */ import { exec } from './parseAction'; +import { exec as parsePushExec } from './parsePush'; export const parseAction = exec; +export const parsePush = parsePushExec; diff --git a/src/proxy/processors/pre-processor/parseAction.ts b/src/proxy/processors/pre-processor/parseAction.ts index e3b846d68..5d54da964 100644 --- a/src/proxy/processors/pre-processor/parseAction.ts +++ b/src/proxy/processors/pre-processor/parseAction.ts @@ -16,21 +16,21 @@ import { Request } from 'express'; -import { Action } from '../../actions'; +import { Action, RequestType } from '../../actions'; import { processUrlPath } from '../../routes/helper'; import * as db from '../../../db'; const exec = async (req: Request) => { const id = Date.now(); const timestamp = id; - let type = 'default'; + let type: RequestType = RequestType.DEFAULT; //inspect content-type headers to classify requests as push or pull operations // see git http protocol docs for more details: https://github.com/git/git/blob/master/Documentation/gitprotocol-http.adoc if (req.headers['content-type'] === 'application/x-git-upload-pack-request') { - type = 'pull'; + type = RequestType.PULL; } else if (req.headers['content-type'] === 'application/x-git-receive-pack-request') { - type = 'push'; + type = RequestType.PUSH; } // Proxy URLs take the form https://:// diff --git a/src/proxy/processors/push-action/parsePush.ts b/src/proxy/processors/pre-processor/parsePush.ts similarity index 83% rename from src/proxy/processors/push-action/parsePush.ts rename to src/proxy/processors/pre-processor/parsePush.ts index 6ee39a4c9..dc003f1c6 100644 --- a/src/proxy/processors/push-action/parsePush.ts +++ b/src/proxy/processors/pre-processor/parsePush.ts @@ -19,14 +19,17 @@ import fs from 'fs'; import lod from 'lodash'; import { createInflate } from 'zlib'; -import { Action, Step } from '../../actions'; +import { Action, Step, PushType } from '../../actions'; import { CommitContent, CommitData, CommitHeader, PackMeta, PersonLine } from '../types'; +import { TagData } from '../../../types/models'; import { - BRANCH_PREFIX, EMPTY_COMMIT_HASH, + REFS_PREFIX, + TAG_PREFIX, PACK_SIGNATURE, PACKET_SIZE, GIT_OBJECT_TYPE_COMMIT, + GIT_OBJECT_TYPE_TAG, } from '../constants'; import { parsePacketLines } from '../pktLineParser'; import { getErrorMessage } from '../../../utils/errors'; @@ -60,35 +63,49 @@ async function exec(req: Request, action: Action): Promise { if (typeof req.body === 'string' || Array.isArray(req.body) || !Buffer.isBuffer(req.body)) { throw new Error('Request body must be a Buffer'); } + const [packetLines, packDataOffset] = parsePacketLines(req.body); - const refUpdates = packetLines.filter((line) => line.includes(BRANCH_PREFIX)); - if (refUpdates.length !== 1) { - step.log('Invalid number of branch updates.'); - step.log(`Expected 1, but got ${refUpdates.length}`); + const refUpdates = packetLines.filter((line) => line.includes(REFS_PREFIX)); + + if (refUpdates.length === 0) { + throw new Error('Your push has been blocked. No ref updates found.'); + } + + const parsedRefs = refUpdates.map((line) => { + const [commitParts] = line.split('\0'); + const parts = commitParts.split(' '); + if (parts.length !== 3) { + throw new Error('Your push has been blocked. Invalid ref update format.'); + } + const refName = parts[2].replace(/\0.*/, '').trim(); + return { + oldCommit: parts[0], + newCommit: parts[1], + refName, + isTag: refName.startsWith(TAG_PREFIX), + }; + }); + + const allTags = parsedRefs.every((r) => r.isTag); + + if (parsedRefs.length > 1 && !allTags) { + step.log(`Received ${parsedRefs.length} ref updates with mixed or multiple branch refs.`); throw new Error( - 'Your push has been blocked. Please make sure you are pushing to a single branch.', + 'Your push has been blocked. Multi-ref pushes are only supported for tags. Please push one branch at a time.', ); } - const [commitParts] = refUpdates[0].split('\0'); - const parts = commitParts.split(' '); - if (parts.length !== 3) { - step.log('Invalid number of parts in ref update.'); - step.log(`Expected 3, but got ${parts.length}`); - throw new Error('Your push has been blocked. Invalid ref update format.'); + if (allTags) { + action.actionType = PushType.TAG; + action.tags = parsedRefs.map((r) => r.refName); + } else { + action.actionType = PushType.BRANCH; + action.branch = parsedRefs[0].refName; } - const [oldCommit, newCommit, ref] = parts; - - // Strip everything after NUL, which is cap-list from - // https://git-scm.com/docs/http-protocol#_smart_server_response - action.branch = ref.replace(/\0.*/, '').trim(); + action.setCommit(parsedRefs[0].oldCommit, parsedRefs[0].newCommit); - // Note this will change the action.id to be based on the commits - action.setCommit(oldCommit, newCommit); - - // Check if the offset is valid and if there's data after it if (packDataOffset >= req.body.length) { step.log('No PACK data found after packet lines.'); throw new Error('Your push has been blocked. PACK data is missing.'); @@ -96,7 +113,6 @@ async function exec(req: Request, action: Action): Promise { const buf = req.body.subarray(packDataOffset); - // Verify that data actually starts with PACK signature if (buf.length < PACKET_SIZE || buf.toString('utf8', 0, PACKET_SIZE) !== PACK_SIGNATURE) { step.log(`Expected PACK signature at offset ${packDataOffset}, but found something else.`); throw new Error('Your push has been blocked. Invalid PACK data structure.'); @@ -104,12 +120,26 @@ async function exec(req: Request, action: Action): Promise { const [meta, contentBuff] = getPackMeta(buf); const contents = await getContents(contentBuff, meta.entries); - action.commitData = getCommitData(contents); + action.commitData = []; + action.tagData = []; - if (action.commitData.length === 0) { - step.log('No commit data found when parsing push.'); - } else { - if (action.commitFrom === EMPTY_COMMIT_HASH) { + for (const obj of contents) { + if (obj.type === GIT_OBJECT_TYPE_COMMIT) action.commitData.push(...getCommitData([obj])); + else if (obj.type === GIT_OBJECT_TYPE_TAG) action.tagData.push(getTagData(obj)); + } + + if (action.actionType === PushType.TAG) { + if (action.tagData.length) { + action.user = action.tagData.at(-1)!.tagger; + action.userEmail = action.tagData.at(-1)!.taggerEmail; + } else { + // TODO: support lightweight tags once we have a reliable way to identify the pusher + throw new Error( + 'Lightweight (non-annotated) tags are not supported. Please use "git tag -a" to create an annotated tag.', + ); + } + } else if (action.actionType === PushType.BRANCH) { + if (action.commitData.length && action.commitFrom === EMPTY_COMMIT_HASH) { action.commitFrom = action.commitData[action.commitData.length - 1].parent; } @@ -118,13 +148,15 @@ async function exec(req: Request, action: Action): Promise { step.log(`Push request received from authenticated user ${username} with email ${email}`); action.user = username; action.userEmail = email; - } else { + } else if (action.commitData.length) { const { committer, committerEmail } = action.commitData[action.commitData.length - 1]; // Note: This is not always the pusher's email, it's the last committer's email. // See https://github.com/finos/git-proxy/issues/1400 step.log(`Push request received from user ${committer} with email ${committerEmail}`); action.user = committer; action.userEmail = committerEmail; + } else { + step.log('No commit data found when parsing push.'); } } @@ -140,6 +172,44 @@ async function exec(req: Request, action: Action): Promise { return action; } +function getTagData(x: CommitContent): TagData { + const lines = x.content.split('\n'); + const object = lines + .find((l) => l.startsWith('object ')) + ?.slice(7) + .trim(); + const typeLine = lines + .find((l) => l.startsWith('type ')) + ?.slice(5) + .trim(); // commit | tree | blob + const tagName = lines + .find((l) => l.startsWith('tag ')) + ?.slice(4) + .trim(); + const rawTagger = lines + .find((l) => l.startsWith('tagger ')) + ?.slice(7) + .trim(); + if (!rawTagger) throw new Error('Invalid tag object: no tagger line'); + + const taggerInfo = parsePersonLine(rawTagger); + + const messageIndex = lines.indexOf(''); + const message = lines.slice(messageIndex + 1).join('\n'); + + if (!object || !typeLine || !tagName || !taggerInfo.name) throw new Error('Invalid tag object'); + + return { + object, + type: typeLine, + tagName, + tagger: taggerInfo.name, + taggerEmail: taggerInfo.email, + timestamp: taggerInfo.timestamp, + message, + }; +} + /** * Parses the name, email, and timestamp from an author or committer line. * @@ -564,4 +634,4 @@ const decompressGitObjects = async (buffer: Buffer): Promise => { exec.displayName = 'parsePush.exec'; -export { exec, getCommitData, getContents, getPackMeta }; +export { exec, getCommitData, getContents, getPackMeta, getTagData }; diff --git a/src/proxy/processors/push-action/checkCommitMessages.ts b/src/proxy/processors/push-action/checkMessages.ts similarity index 80% rename from src/proxy/processors/push-action/checkCommitMessages.ts rename to src/proxy/processors/push-action/checkMessages.ts index 29b827c1a..e1b111582 100644 --- a/src/proxy/processors/push-action/checkCommitMessages.ts +++ b/src/proxy/processors/push-action/checkMessages.ts @@ -66,32 +66,31 @@ const isMessageAllowed = (commitMessage: string, step: Step): boolean => { return true; }; -// Execute if the repo is approved const exec = async (_req: Request, action: Action): Promise => { - const step = new Step('checkCommitMessages'); + const step = new Step('checkMessages'); - const uniqueCommitMessages = [...new Set(action.commitData?.map((commit) => commit.message))]; + const commitMessages = action.commitData?.map((commit) => commit.message) ?? []; + const tagMessages = action.tagData?.map((tag) => tag.message) ?? []; + const uniqueMessages = [...new Set([...commitMessages, ...tagMessages])]; - const illegalMessages = uniqueCommitMessages.filter( - (message) => !isMessageAllowed(message, step), - ); + const illegalMessages = uniqueMessages.filter((message) => !isMessageAllowed(message, step)); if (illegalMessages.length > 0) { step.error = true; - step.log(`The following commit messages are illegal: ${illegalMessages}`); + step.log(`The following messages are illegal: ${illegalMessages}`); step.setError( - `\n\n\nYour push has been blocked.\nPlease ensure your commit message(s) does not contain sensitive information or URLs.\n\nThe following commit messages are illegal: ${JSON.stringify(illegalMessages)}\n\n`, + `\n\n\nYour push has been blocked.\nPlease ensure your commit/tag message(s) does not contain sensitive information or URLs.\n\nThe following messages are illegal: ${JSON.stringify(illegalMessages)}\n\n`, ); action.addStep(step); return action; } - step.log(`The following commit messages are legal: ${uniqueCommitMessages}`); + step.log(`The following messages are legal: ${uniqueMessages}`); action.addStep(step); return action; }; -exec.displayName = 'checkCommitMessages.exec'; +exec.displayName = 'checkMessages.exec'; export { exec }; diff --git a/src/proxy/processors/push-action/index.ts b/src/proxy/processors/push-action/index.ts index 5bcb0d0f3..721b29c60 100644 --- a/src/proxy/processors/push-action/index.ts +++ b/src/proxy/processors/push-action/index.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { exec as parsePush } from './parsePush'; import { exec as preReceive } from './preReceive'; import { exec as checkRepoInAuthorisedList } from './checkRepoInAuthorisedList'; @@ -26,14 +25,13 @@ import { exec as gitleaks } from './gitleaks'; import { exec as scanDiff } from './scanDiff'; import { exec as blockForAuth } from './blockForAuth'; import { exec as checkIfWaitingAuth } from './checkIfWaitingAuth'; -import { exec as checkCommitMessages } from './checkCommitMessages'; +import { exec as checkMessages } from './checkMessages'; import { exec as checkAuthorEmails } from './checkAuthorEmails'; import { exec as checkUserPushPermission } from './checkUserPushPermission'; import { exec as checkEmptyBranch } from './checkEmptyBranch'; export { - parsePush, preReceive, checkRepoInAuthorisedList, pullRemote, @@ -44,7 +42,7 @@ export { scanDiff, blockForAuth, checkIfWaitingAuth, - checkCommitMessages, + checkMessages, checkAuthorEmails, checkUserPushPermission, checkEmptyBranch, diff --git a/src/types/models.ts b/src/types/models.ts new file mode 100644 index 000000000..6861d857f --- /dev/null +++ b/src/types/models.ts @@ -0,0 +1,155 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +interface AttestationReviewer { + username: string; + gitAccount: string; +} + +interface AttestationQuestion { + label: string; + checked: boolean; +} + +export interface AttestationData { + reviewer: AttestationReviewer; + timestamp: string | Date; + questions: AttestationQuestion[]; +} + +export interface UserData { + id: string; + name: string; + username: string; + email?: string; + displayName?: string; + title?: string; + gitAccount?: string; + admin?: boolean; +} + +export interface CommitData { + commitTs?: number; + message: string; + committer: string; + committerEmail: string; + tree?: string; + parent?: string; + author: string; + authorEmail: string; + commitTimestamp?: number; +} + +export interface TagData { + object?: string; + type: string; // commit | tree | blob | tag or 'lightweight' | 'annotated' for legacy + tagName: string; + tagger: string; + taggerEmail?: string; + timestamp?: string; + message: string; +} + +export interface PushData { + id: string; + url: string; + repo: string; + branch: string; + commitFrom: string; + commitTo: string; + commitData: CommitData[]; + diff: { + content: string; + }; + canceled?: boolean; + rejected?: boolean; + authorised?: boolean; + attestation?: AttestationData; + autoApproved?: boolean; + timestamp: string | Date; + // Tag-specific fields + tags?: string[]; + tagData?: TagData[]; + user?: string; // Used for tag pushes as the tagger +} + +export interface Route { + path: string; + layout: string; + name: string; + rtlName?: string; + component: React.ComponentType; + icon?: string | React.ComponentType; + visible?: boolean; +} + +export interface GitHubRepositoryMetadata { + description?: string; + language?: string; + license?: { + spdx_id: string; + }; + html_url: string; + parent?: { + full_name: string; + html_url: string; + }; + created_at?: string; + updated_at?: string; + pushed_at?: string; + owner?: { + avatar_url: string; + html_url: string; + }; +} + +export interface GitLabRepositoryMetadata { + description?: string; + primary_language?: string; + license?: { + nickname: string; + }; + web_url: string; + forked_from_project?: { + full_name: string; + web_url: string; + }; + last_activity_at?: string; + avatar_url?: string; + namespace?: { + name: string; + path: string; + full_path: string; + avatar_url?: string; + web_url: string; + }; +} + +export interface SCMRepositoryMetadata { + description?: string; + language?: string; + license?: string; + htmlUrl?: string; + parentName?: string; + parentUrl?: string; + lastUpdated?: string; + created_at?: string; + updated_at?: string; + pushed_at?: string; + + profileUrl?: string; + avatarUrl?: string; +} diff --git a/src/ui/utils.tsx b/src/ui/utils.tsx index f8746a8f0..f1c330114 100644 --- a/src/ui/utils.tsx +++ b/src/ui/utils.tsx @@ -14,8 +14,8 @@ * limitations under the License. */ -import axios from 'axios'; import React from 'react'; +import axios from 'axios'; import { GitHubRepositoryMetadata, GitLabRepositoryMetadata, SCMRepositoryMetadata } from './types'; import { CommitData } from '../proxy/processors/types'; import moment from 'moment'; @@ -120,20 +120,22 @@ export const getUserProfileUrl = (username: string, provider: string, hostname: * @param {string} username The username. * @param {string} provider The name of the SCM provider. * @param {string} hostname The hostname of the SCM provider. - * @return {string} A string containing an HTML A tag pointing to the user's profile, if possible, degrading to just the username or 'N/A' when not (e.g. because the SCM provider is unknown). + * @return {JSX.Element} A JSX element containing a link to the user's profile, if possible, degrading to just the username or 'N/A' when not (e.g. because the SCM provider is unknown). */ export const getUserProfileLink = (username: string, provider: string, hostname: string) => { if (username) { - let profileData = ''; const profileUrl = getUserProfileUrl(username, provider, hostname); if (profileUrl) { - profileData = `${username}`; + return ( + + {username} + + ); } else { - profileData = `${username}`; + return {username}; } - return profileData; } else { - return 'N/A'; + return N/A; } }; diff --git a/src/ui/utils/pushUtils.ts b/src/ui/utils/pushUtils.ts new file mode 100644 index 000000000..97ac47efb --- /dev/null +++ b/src/ui/utils/pushUtils.ts @@ -0,0 +1,265 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import moment from 'moment'; +import { CommitData, PushData, TagData } from '../../types/models'; +import { trimPrefixRefsHeads, trimTrailingDotGit } from '../../db/helper'; + +/** + * Determines if a push is a tag push + * @param {PushData} pushData - The push data to check + * @return {boolean} True if this is a tag push, false otherwise + */ +export const isTagPush = (pushData: PushData): boolean => { + return Boolean( + pushData?.tags && pushData.tags.length > 0 && pushData?.tagData && pushData.tagData.length > 0, + ); +}; + +/** + * Type guard to distinguish TagData from CommitData + */ +const isTagData = (data: CommitData | TagData): data is TagData => 'tagName' in data; + +/** + * Gets the display timestamp for a push (handles both commits and tags) + * @param {CommitData | TagData | null | undefined} data - The commit or tag data + * @return {string} Formatted timestamp string or 'N/A' + */ +export const getDisplayTimestamp = (data?: CommitData | TagData | null): string => { + if (!data) return 'N/A'; + + if (isTagData(data)) { + return data.timestamp ? moment.unix(parseInt(data.timestamp)).toString() : 'N/A'; + } + + const timestamp = data.commitTimestamp || data.commitTs; + return timestamp ? moment.unix(timestamp).toString() : 'N/A'; +}; + +/** + * Safely extracts tag names from git references + * @param {string[]} [tagRefs] - The git tag references (e.g., ['refs/tags/v1.0.0']) + * @return {string} Comma-separated tag names without the 'refs/tags/' prefix + */ +export const getTagName = (tagRefs?: string[]): string => { + if (!tagRefs || !Array.isArray(tagRefs) || tagRefs.length === 0) return ''; + return tagRefs.map((ref) => ref.replace('refs/tags/', '')).join(', '); +}; + +/** + * Gets the first tag name (for use in URLs where only one ref can be linked) + * @param {string[]} [tagRefs] - The git tag references + * @return {string} The first tag name without the 'refs/tags/' prefix + */ +export const getFirstTagName = (tagRefs?: string[]): string => { + if (!tagRefs || !Array.isArray(tagRefs) || tagRefs.length === 0) return ''; + return tagRefs[0].replace('refs/tags/', ''); +}; + +/** + * Gets the appropriate reference to show (tag name or branch name) + * @param {PushData} pushData - The push data + * @return {string} The reference name to display + */ +export const getRefToShow = (pushData: PushData): string => { + if (isTagPush(pushData)) { + return getTagName(pushData.tags); + } + if (pushData?.tags && pushData.tags.length > 0) { + return getTagName(pushData.tags); + } + return trimPrefixRefsHeads(pushData.branch || ''); +}; + +/** + * Gets the SHA or tag identifier for display + * @param {PushData} pushData - The push data + * @return {string} The SHA (shortened) or tag name + */ +export const getShaOrTag = (pushData: PushData): string => { + if (isTagPush(pushData)) { + return getFirstTagName(pushData.tags); + } + + if (!pushData.commitTo || typeof pushData.commitTo !== 'string') { + console.warn('Invalid commitTo value:', pushData.commitTo); + return 'N/A'; + } + + return pushData.commitTo.substring(0, 8); +}; + +/** + * Gets the committer or tagger based on push type + * @param {PushData} pushData - The push data + * @return {string} The committer username for commits or tagger for tags + */ +export const getCommitterOrTagger = (pushData: PushData): string => { + if (isTagPush(pushData) && pushData.user) { + return pushData.user; + } + + if ( + !pushData.commitData || + !Array.isArray(pushData.commitData) || + pushData.commitData.length === 0 + ) { + console.warn('Invalid or empty commitData:', pushData.commitData); + return 'N/A'; + } + + return pushData.commitData[0]?.committer || 'N/A'; +}; + +/** + * Gets the author (tagger for tag pushes) + * @param {PushData} pushData - The push data + * @return {string} The author username for commits or tagger for tags + */ +export const getAuthor = (pushData: PushData): string => { + if (isTagPush(pushData)) { + return pushData.tagData?.[0]?.tagger || 'N/A'; + } + return pushData.commitData?.[0]?.author || 'N/A'; +}; + +/** + * Gets the author email (tagger email for tag pushes) + * @param {PushData} pushData - The push data + * @return {string} The author email for commits or tagger email for tags + */ +export const getAuthorEmail = (pushData: PushData): string => { + if (isTagPush(pushData)) { + return pushData.tagData?.[0]?.taggerEmail || 'N/A'; + } + return pushData.commitData?.[0]?.authorEmail || 'N/A'; +}; + +/** + * Gets the message (tag message or commit message) + * @param {PushData} pushData - The push data + * @return {string} The appropriate message for the push type + */ +export const getMessage = (pushData: PushData): string => { + if (isTagPush(pushData)) { + // For tags, try tag message first, then fallback to commit message + return pushData.tagData?.[0]?.message || pushData.commitData?.[0]?.message || ''; + } + return pushData.commitData?.[0]?.message || 'N/A'; +}; + +/** + * Gets the commit count + * @param {PushData} pushData - The push data + * @return {number} The number of commits in the push + */ +export const getCommitCount = (pushData: PushData): number => { + return pushData.commitData?.length || 0; +}; + +/** + * Gets the cleaned repository name + * @param {string} repo - The repository name (may include .git suffix) + * @return {string} The cleaned repository name without .git suffix + */ +export const getRepoFullName = (repo: string): string => { + return trimTrailingDotGit(repo); +}; + +/** + * Generates GitHub URLs for different reference types (legacy - use getGitUrl instead) + */ +export const getGitHubUrl = { + repo: (repoName: string) => `https://github.com/${repoName}`, + commit: (repoName: string, sha: string) => `https://github.com/${repoName}/commit/${sha}`, + branch: (repoName: string, branch: string) => `https://github.com/${repoName}/tree/${branch}`, + tag: (repoName: string, tagName: string) => + `https://github.com/${repoName}/releases/tag/${tagName}`, + user: (username: string) => `https://github.com/${username}`, +}; + +/** + * Generates URLs for different Git providers and reference types + * @param {string} repoWebUrl - The base repository web URL + * @param {string} gitProvider - The Git provider (github, gitlab, etc.) + * @return {object} Object with URL generation functions + */ +export const getGitUrl = (repoWebUrl: string, gitProvider: string) => ({ + repo: () => repoWebUrl, + commit: (sha: string) => `${repoWebUrl}/commit/${sha}`, + branch: (branch: string) => { + switch (gitProvider) { + case 'gitlab': + return `${repoWebUrl}/-/tree/${branch}`; + default: + return `${repoWebUrl}/tree/${branch}`; + } + }, + tag: (tagName: string) => { + switch (gitProvider) { + case 'gitlab': + return `${repoWebUrl}/-/tags/${tagName}`; + default: + return `${repoWebUrl}/releases/tag/${tagName}`; + } + }, +}); + +/** + * Gets the appropriate URL for a branch or tag reference + * @param {string} repoWebUrl - The base repository web URL + * @param {string} gitProvider - The Git provider + * @param {boolean} isTag - Whether this is a tag reference + * @param {string} refName - The reference name (branch or tag) + * @return {string} The appropriate URL + */ +export const getRefUrl = ( + repoWebUrl: string, + gitProvider: string, + isTag: boolean, + refName: string, +): string => { + const gitUrl = getGitUrl(repoWebUrl, gitProvider); + return isTag ? gitUrl.tag(refName) : gitUrl.branch(refName); +}; + +/** + * Gets the appropriate URL for a commit or tag SHA + * @param {string} repoWebUrl - The base repository web URL + * @param {string} gitProvider - The Git provider + * @param {boolean} isTag - Whether this is a tag reference + * @param {string} sha - The SHA or tag name + * @return {string} The appropriate URL + */ +export const getShaUrl = ( + repoWebUrl: string, + gitProvider: string, + isTag: boolean, + sha: string, +): string => { + const gitUrl = getGitUrl(repoWebUrl, gitProvider); + return isTag ? gitUrl.tag(sha) : gitUrl.commit(sha); +}; + +/** + * Checks if a value is not "N/A" and not empty + * @param {string | undefined} value - The value to check + * @return {boolean} True if the value is valid (not N/A and not empty) + */ +export const isValidValue = (value: string | undefined): value is string => { + return Boolean(value && value !== 'N/A'); +}; diff --git a/src/ui/views/PushDetails/PushDetails.tsx b/src/ui/views/PushDetails/PushDetails.tsx index 09768cff5..5fd2e6e62 100644 --- a/src/ui/views/PushDetails/PushDetails.tsx +++ b/src/ui/views/PushDetails/PushDetails.tsx @@ -39,9 +39,21 @@ import { CheckCircle, Visibility, Cancel, Block, List as ListIcon } from '@mater import CodeIcon from '@material-ui/icons/Code'; import TimelineIcon from '@material-ui/icons/Timeline'; import Snackbar from '@material-ui/core/Snackbar'; +import Table from '@material-ui/core/Table'; +import TableHead from '@material-ui/core/TableHead'; +import TableBody from '@material-ui/core/TableBody'; +import TableRow from '@material-ui/core/TableRow'; +import TableCell from '@material-ui/core/TableCell'; import { PushActionView } from '../../types'; -import { trimPrefixRefsHeads, trimTrailingDotGit } from '../../../db/helper'; -import { getGitProvider } from '../../utils'; +import { + isTagPush, + getTagName, + getRepoFullName, + getRefToShow, + getGitUrl, +} from '../../utils/pushUtils'; +import { trimTrailingDotGit } from '../../../db/helper'; +import { generateEmailLink, getGitProvider } from '../../utils'; const Dashboard: React.FC = () => { const { id } = useParams<{ id: string }>(); @@ -104,7 +116,7 @@ const Dashboard: React.FC = () => { if (!id) return; const result = await cancelPush(id); if (result.success) { - navigate(`/dashboard/push/`); + navigate('/dashboard/push/'); return; } handleActionFailure(result); @@ -142,12 +154,14 @@ const Dashboard: React.FC = () => { }; } - const repoFullName = trimTrailingDotGit(push.repo); - const repoBranch = trimPrefixRefsHeads(push.branch ?? ''); + const isTag = isTagPush(push as any); + const repoFullName = getRepoFullName(push.repo); + const refToShow = getRefToShow(push as any); const repoUrl = push.url; const repoWebUrl = trimTrailingDotGit(repoUrl); const gitProvider = getGitProvider(repoUrl); const isGitHub = gitProvider == 'github'; + const gitUrl = getGitUrl(repoWebUrl, gitProvider); const generateIcon = (title: string) => { switch (title) { @@ -208,42 +222,39 @@ const Dashboard: React.FC = () => {

{moment(push.timestamp).toString()}

-

Remote Head

+

Repository

- - {push.commitFrom} + + {repoFullName}

-

Commit SHA

-

- - {push.commitTo} - -

+ {isTag ? ( + <> +

{(push as any).tags?.length > 1 ? 'Tags' : 'Tag'}

+

{getTagName((push as any).tags)}

+ + ) : ( + <> +

Branch

+

{refToShow}

+ + )}
-

Repository

+

From

- - {repoFullName} + + {push.commitFrom}

-

Branch

+

To

- - {repoBranch} + + {push.commitTo}

@@ -254,24 +265,73 @@ const Dashboard: React.FC = () => { , - }, - { - tabName: 'Changes', - tabIcon: CodeIcon, - tabContent: , - }, - { - tabName: 'Steps', - tabIcon: TimelineIcon, - tabContent: , - badge: errorCount > 0 ? errorCount : undefined, - }, - ]} + tabs={ + isTag + ? [ + { + tabName: 'Tag Details', + tabIcon: ListIcon, + tabContent: ( + + + + Tag Name + Tagger + Message + + + + {((push as any).tags ?? []).map((ref: string) => { + const name = ref.replace('refs/tags/', ''); + const data = (push as any).tagData?.find( + (t: any) => t.tagName === name, + ); + const fallbackUser = (push as any).user; + const fallbackEmail = (push as any).userEmail; + return ( + + {name} + + {data + ? generateEmailLink(data.tagger, data.taggerEmail) + : fallbackUser + ? generateEmailLink(fallbackUser, fallbackEmail) + : 'N/A'} + + {data?.message || '-'} + + ); + })} + +
+ ), + }, + { + tabName: 'Steps', + tabIcon: TimelineIcon, + tabContent: , + badge: errorCount > 0 ? errorCount : undefined, + }, + ] + : [ + { + tabName: 'Commits', + tabIcon: ListIcon, + tabContent: , + }, + { + tabName: 'Changes', + tabIcon: CodeIcon, + tabContent: , + }, + { + tabName: 'Steps', + tabIcon: TimelineIcon, + tabContent: , + badge: errorCount > 0 ? errorCount : undefined, + }, + ] + } />
diff --git a/src/ui/views/PushRequests/components/PushesTable.tsx b/src/ui/views/PushRequests/components/PushesTable.tsx index defed1e60..e335ee079 100644 --- a/src/ui/views/PushRequests/components/PushesTable.tsx +++ b/src/ui/views/PushRequests/components/PushesTable.tsx @@ -15,7 +15,6 @@ */ import React, { useState, useEffect } from 'react'; -import moment from 'moment'; import { useNavigate } from 'react-router-dom'; import Button from '@material-ui/core/Button'; import Table from '@material-ui/core/Table'; @@ -29,9 +28,26 @@ import { getPushes } from '../../../services/git-push'; import { KeyboardArrowRight } from '@material-ui/icons'; import Search from '../../../components/Search/Search'; import Pagination from '../../../components/Pagination/Pagination'; +import { ErrorBoundary } from '../../../components/ErrorBoundary/ErrorBoundary'; import { PushActionView } from '../../../types'; -import { trimPrefixRefsHeads, trimTrailingDotGit } from '../../../../db/helper'; -import { generateAuthorLinks, generateEmailLink } from '../../../utils'; +import { + isTagPush, + getDisplayTimestamp, + getTagName, + getFirstTagName, + getRefToShow, + getShaOrTag, + getCommitterOrTagger, + getAuthorEmail, + getMessage, + getCommitCount, + getRepoFullName, + isValidValue, + getRefUrl, + getShaUrl, +} from '../../../utils/pushUtils'; +import { trimTrailingDotGit } from '../../../../db/helper'; +import { getGitProvider, generateAuthorLinks, generateEmailLink } from '../../../utils'; interface PushesTableProps { blocked?: boolean; @@ -61,7 +77,7 @@ const PushesTable: React.FC = (props) => { if (props.canceled !== undefined) query.canceled = props.canceled; if (props.authorised !== undefined) query.authorised = props.authorised; if (props.rejected !== undefined) query.rejected = props.rejected; - if (props.errored !== undefined) query.errored = props.errored; + if (props.errored !== undefined) query.error = props.errored; const load = async () => { setIsLoading(true); @@ -84,15 +100,24 @@ const PushesTable: React.FC = (props) => { setFilteredData(pushes); }, [pushes]); + // Include "tag" in the searchable fields when tag exists useEffect(() => { const lowerCaseTerm = searchTerm.toLowerCase(); const filtered = searchTerm - ? pushes.filter( - (item) => - item.repo.toLowerCase().includes(lowerCaseTerm) || - item.commitTo?.toLowerCase().includes(lowerCaseTerm) || - item.commitData?.[0]?.message.toLowerCase().includes(lowerCaseTerm), - ) + ? pushes.filter((item) => { + const row = item as any; + const repoName = getRepoFullName(row.repo).toLowerCase(); + const message = getMessage(row).toLowerCase(); + const commitToSha = (row.commitTo ?? '').toLowerCase(); + const tagName = getTagName(row.tags).toLowerCase(); + + return ( + repoName.includes(lowerCaseTerm) || + commitToSha.includes(lowerCaseTerm) || + message.includes(lowerCaseTerm) || + tagName.includes(lowerCaseTerm) + ); + }) : pushes; setFilteredData(filtered); setCurrentPage(1); @@ -111,93 +136,103 @@ const PushesTable: React.FC = (props) => { if (isLoading) return
Loading...
; return ( -
- - - - - - Timestamp - Repository - Branch - Commit SHA - Committer - Authors - Commit Message - No. of Commits - - - - - {currentItems.map((row) => { - const repoFullName = trimTrailingDotGit(row.repo); - const repoBranch = trimPrefixRefsHeads(row.branch ?? ''); - const repoUrl = row.url; - const repoWebUrl = trimTrailingDotGit(repoUrl); - // may be used to resolve users to profile links in future - // const gitProvider = getGitProvider(repoUrl); - // const hostname = new URL(repoUrl).hostname; - const commitTimestamp = row.commitData?.[0]?.commitTimestamp; - - return ( - - - {commitTimestamp ? moment.unix(Number(commitTimestamp)).toString() : 'N/A'} - - - - {repoFullName} - - - - - {repoBranch} - - - - - {row.commitTo?.substring(0, 8)} - - - - {/* render github/gitlab profile links in future - {getUserProfileLink(row.commitData[0].committerEmail, gitProvider, hostname)} - */} - {generateEmailLink( - row.commitData?.[0]?.committer ?? '', - row.commitData?.[0]?.committerEmail ?? '', - )} - - - {/* render github/gitlab profile links in future - {getUserProfileLink(row.commitData[0].authorEmail, gitProvider, hostname)} - */} - {generateAuthorLinks(row.commitData ?? [])} - - {row.commitData?.[0]?.message || 'N/A'} - {row.commitData?.length ?? 0} - - - - - ); - })} - -
-
- -
+ +
+ + + + + + Timestamp + Repository + Branch/Tag + Commit SHA/Tag + Committer/Tagger + Authors + Message + No. of Commits + + + + + {[...currentItems].reverse().map((row) => { + const r = row as any; + const isTag = isTagPush(r); + const repoFullName = getRepoFullName(r.repo); + const displayTime = getDisplayTimestamp(isTag ? r.tagData?.[0] : r.commitData?.[0]); + const refToShow = getRefToShow(r); + const shaOrTag = getShaOrTag(r); + const repoUrl = r.url; + const repoWebUrl = trimTrailingDotGit(repoUrl); + const gitProvider = getGitProvider(repoUrl); + // const hostname = new URL(repoUrl).hostname; // may be used to resolve users to profile links in future + const committerOrTagger = getCommitterOrTagger(r); + const message = getMessage(r); + const commitCount = getCommitCount(r); + + return ( + + {displayTime} + + + {repoFullName} + + + + + {refToShow} + + + + + {shaOrTag} + + + + {isValidValue(committerOrTagger) + ? generateEmailLink(committerOrTagger, getAuthorEmail(r)) + : 'N/A'} + + {generateAuthorLinks(r.commitData ?? [])} + {message} + {commitCount} + + + + + ); + })} + +
+
+ +
+
); }; diff --git a/test/chain.test.ts b/test/chain.test.ts index 44eb6750f..215da7632 100644 --- a/test/chain.test.ts +++ b/test/chain.test.ts @@ -16,7 +16,7 @@ import { describe, it, beforeEach, afterEach, expect, vi } from 'vitest'; import { PluginLoader } from '../src/plugin'; -import { Action } from '../src/proxy/actions'; +import { Action, PushType, RequestType } from '../src/proxy/actions'; const mockLoader = { pushPlugins: [ @@ -29,10 +29,9 @@ const mockLoader = { const initMockPushProcessors = () => { return { - parsePush: vi.fn(), checkEmptyBranch: vi.fn(), checkRepoInAuthorisedList: vi.fn(), - checkCommitMessages: vi.fn(), + checkMessages: vi.fn(), checkAuthorEmails: vi.fn(), checkUserPushPermission: vi.fn(), checkIfWaitingAuth: vi.fn(), @@ -57,6 +56,7 @@ const initMockPostProcessors = () => { const mockPreProcessors = { parseAction: vi.fn(), + parsePush: vi.fn(), }; describe('proxy chain', function () { @@ -113,14 +113,14 @@ describe('proxy chain', function () { it('getChain should set pluginLoaded if loader is undefined', async () => { chain.chainPluginLoader = undefined; const actual = await chain.getChain({ type: 'push' }); - expect(actual).toEqual(chain.pushActionChain); + expect(actual).toEqual(chain.branchPushChain); expect(chain.chainPluginLoader).toBeUndefined(); expect(chain.pluginsInserted).toBe(true); }); it('getChain should load plugins from an initialized PluginLoader', async () => { chain.chainPluginLoader = mockLoader; - const initialChain = [...chain.pushActionChain]; + const initialChain = [...chain.branchPushChain]; const actual = await chain.getChain({ type: 'push' }); expect(actual.length).toBeGreaterThan(initialChain.length); expect(chain.pluginsInserted).toBe(true); @@ -140,7 +140,7 @@ describe('proxy chain', function () { const action = { type: 'push' } as Action; mockPreProcessors.parseAction.mockResolvedValue(action); - mockPushProcessors.parsePush.mockResolvedValue(continuingAction); + mockPreProcessors.parsePush.mockResolvedValue(continuingAction); // this stops the chain from further execution mockPushProcessors.checkIfWaitingAuth.mockResolvedValue({ @@ -153,10 +153,11 @@ describe('proxy chain', function () { //all processors upto checkIfWaitingAuth should have run + clearBareClone & audit expect(mockPreProcessors.parseAction).toHaveBeenCalled(); - expect(mockPushProcessors.parsePush).toHaveBeenCalled(); + expect(mockPreProcessors.parsePush).toHaveBeenCalled(); + expect(mockPushProcessors.checkEmptyBranch).toHaveBeenCalled(); expect(mockPushProcessors.checkRepoInAuthorisedList).toHaveBeenCalled(); - expect(mockPushProcessors.checkCommitMessages).toHaveBeenCalled(); + expect(mockPushProcessors.checkMessages).toHaveBeenCalled(); expect(mockPushProcessors.checkAuthorEmails).toHaveBeenCalled(); expect(mockPushProcessors.checkUserPushPermission).toHaveBeenCalled(); expect(mockPushProcessors.pullRemote).toHaveBeenCalled(); @@ -185,7 +186,7 @@ describe('proxy chain', function () { const action = { type: 'push' } as Action; mockPreProcessors.parseAction.mockResolvedValue(action); - mockPushProcessors.parsePush.mockResolvedValue(continuingAction); + mockPreProcessors.parsePush.mockResolvedValue(continuingAction); // this stops the chain from further execution mockPushProcessors.checkIfWaitingAuth.mockResolvedValue({ @@ -198,10 +199,11 @@ describe('proxy chain', function () { //all processors upto checkIfWaitingAuth should have run + clearBareClone & audit expect(mockPreProcessors.parseAction).toHaveBeenCalled(); - expect(mockPushProcessors.parsePush).toHaveBeenCalled(); + expect(mockPreProcessors.parsePush).toHaveBeenCalled(); + expect(mockPushProcessors.checkEmptyBranch).toHaveBeenCalled(); expect(mockPushProcessors.checkRepoInAuthorisedList).toHaveBeenCalled(); - expect(mockPushProcessors.checkCommitMessages).toHaveBeenCalled(); + expect(mockPushProcessors.checkMessages).toHaveBeenCalled(); expect(mockPushProcessors.checkAuthorEmails).toHaveBeenCalled(); expect(mockPushProcessors.checkUserPushPermission).toHaveBeenCalled(); expect(mockPushProcessors.pullRemote).toHaveBeenCalled(); @@ -230,16 +232,17 @@ describe('proxy chain', function () { const action = { type: 'push' } as Action; mockPreProcessors.parseAction.mockResolvedValue(action); - mockPushProcessors.parsePush.mockResolvedValue(continuingAction); + mockPreProcessors.parsePush.mockResolvedValue(continuingAction); const result = await chain.executeChain(req); - //all processors upto checkIfWaitingAuth should have run + clearBareClone & audit + //all processors should have run + clearBareClone & audit expect(mockPreProcessors.parseAction).toHaveBeenCalled(); - expect(mockPushProcessors.parsePush).toHaveBeenCalled(); + expect(mockPreProcessors.parsePush).toHaveBeenCalled(); + expect(mockPushProcessors.checkEmptyBranch).toHaveBeenCalled(); expect(mockPushProcessors.checkRepoInAuthorisedList).toHaveBeenCalled(); - expect(mockPushProcessors.checkCommitMessages).toHaveBeenCalled(); + expect(mockPushProcessors.checkMessages).toHaveBeenCalled(); expect(mockPushProcessors.checkAuthorEmails).toHaveBeenCalled(); expect(mockPushProcessors.checkUserPushPermission).toHaveBeenCalled(); expect(mockPushProcessors.pullRemote).toHaveBeenCalled(); @@ -270,7 +273,8 @@ describe('proxy chain', function () { const result = await chain.executeChain(req); expect(mockPushProcessors.checkRepoInAuthorisedList).toHaveBeenCalled(); - expect(mockPushProcessors.parsePush).not.toHaveBeenCalled(); + expect(mockPreProcessors.parsePush).not.toHaveBeenCalled(); + expect(mockPostProcessors.audit).toHaveBeenCalled(); expect(mockPostProcessors.clearBareClone).not.toHaveBeenCalled(); expect(result.type).toBe('pull'); @@ -281,7 +285,7 @@ describe('proxy chain', function () { const action = { type: 'push', continue: () => true, allowPush: false }; processors.pre.parseAction.mockResolvedValue(action); - mockPushProcessors.parsePush.mockRejectedValue(new Error('Audit error')); + processors.pre.parsePush.mockRejectedValue(new Error('Audit error')); try { await chain.executeChain(req); @@ -297,6 +301,7 @@ describe('proxy chain', function () { const action = { type: 'push', continue: () => true, allowPush: false }; processors.pre.parseAction.mockResolvedValue(action); + processors.pre.parsePush.mockResolvedValue(action); mockPushProcessors.writePack.mockRejectedValue(new Error('writePack error')); try { @@ -333,6 +338,7 @@ describe('proxy chain', function () { }; mockPreProcessors.parseAction.mockResolvedValue(action); + mockPreProcessors.parsePush.mockResolvedValue(action); mockPushProcessors.preReceive.mockResolvedValue({ ...action, @@ -366,6 +372,7 @@ describe('proxy chain', function () { }; mockPreProcessors.parseAction.mockResolvedValue(action); + mockPreProcessors.parsePush.mockResolvedValue(action); mockPushProcessors.preReceive.mockResolvedValue({ ...action, @@ -398,6 +405,7 @@ describe('proxy chain', function () { }; mockPreProcessors.parseAction.mockResolvedValue(action); + mockPreProcessors.parsePush.mockResolvedValue(action); mockPushProcessors.preReceive.mockResolvedValue({ ...action, @@ -428,6 +436,7 @@ describe('proxy chain', function () { }; mockPreProcessors.parseAction.mockResolvedValue(action); + mockPreProcessors.parsePush.mockResolvedValue(action); mockPushProcessors.preReceive.mockResolvedValue({ ...action, @@ -444,4 +453,58 @@ describe('proxy chain', function () { expect(consoleErrorSpy).toHaveBeenCalledWith('Error during auto-rejection: Database error'); }); + + it('returns pullActionChain for pull actions', async () => { + const action = new Action( + '1', + RequestType.PULL, + 'GET', + Date.now(), + 'https://github.com/owner/repo.git', + ); + const pullChain = await chain.getChain(action); + expect(pullChain).toEqual(chain.pullActionChain); + }); + + it('returns tagPushChain when action.type is push and action.actionType is TAG', async () => { + const action = new Action( + '2', + RequestType.PUSH, + 'POST', + Date.now(), + 'https://github.com/owner/repo.git', + ); + action.actionType = PushType.TAG; + const tagChain = await chain.getChain(action); + expect(tagChain).toEqual(chain.tagPushChain); + }); + + it('returns branchPushChain when action.type is push and actionType is BRANCH', async () => { + const action = new Action( + '3', + RequestType.PUSH, + 'POST', + Date.now(), + 'https://github.com/owner/repo.git', + ); + action.actionType = PushType.BRANCH; + const branchChain = await chain.getChain(action); + expect(branchChain).toEqual(chain.branchPushChain); + }); + + it('getChain should return tagPushChain if loader is undefined for tag pushes', async () => { + chain.chainPluginLoader = undefined; + const actual = await chain.getChain({ type: RequestType.PUSH, actionType: PushType.TAG }); + expect(actual).toEqual(chain.tagPushChain); + expect(chain.chainPluginLoader).toBeUndefined(); + expect(chain.pluginsInserted).toBe(true); + }); + + it('getChain should load tag plugins from an initialized PluginLoader', async () => { + chain.chainPluginLoader = mockLoader; + const initialChain = [...chain.tagPushChain]; + const actual = await chain.getChain({ type: RequestType.PUSH, actionType: PushType.TAG }); + expect(actual.length).toBeGreaterThan(initialChain.length); + expect(chain.pluginsInserted).toBe(true); + }); }); diff --git a/test/db/mongo/push.test.ts b/test/db/mongo/push.test.ts index ecc1c831a..42fab9cca 100644 --- a/test/db/mongo/push.test.ts +++ b/test/db/mongo/push.test.ts @@ -113,9 +113,12 @@ describe('MongoDB Push Handler', async () => { rejected: 1, repo: 1, repoName: 1, + tag: 1, + tagData: 1, timestamp: 1, type: 1, url: 1, + user: 1, }, sort: { timestamp: -1, diff --git a/test/fixtures/test-package/package-lock.json b/test/fixtures/test-package/package-lock.json index cc9cabe8f..ad5274ffa 100644 --- a/test/fixtures/test-package/package-lock.json +++ b/test/fixtures/test-package/package-lock.json @@ -13,36 +13,40 @@ }, "../../..": { "name": "@finos/git-proxy", - "version": "2.0.0-rc.3", + "version": "2.0.0", "license": "Apache-2.0", "workspaces": [ "./packages/git-proxy-cli" ], "dependencies": { - "@aws-sdk/credential-providers": "^3.940.0", + "@aws-sdk/credential-providers": "^3.980.0", + "@fontsource/roboto": "^5.2.9", "@material-ui/core": "^4.12.4", "@material-ui/icons": "4.11.3", - "@primer/octicons-react": "^19.21.0", + "@primer/octicons-react": "^19.21.2", "@seald-io/nedb": "^4.1.2", - "axios": "^1.13.2", + "axios": "^1.13.4", "bcryptjs": "^3.0.3", "clsx": "^2.1.1", "concurrently": "^9.2.1", "connect-mongo": "^5.1.0", - "cors": "^2.8.5", - "diff2html": "^3.4.52", + "cors": "^2.8.6", + "diff2html": "^3.4.56", "env-paths": "^3.0.0", "escape-string-regexp": "^5.0.0", - "express": "^5.1.0", + "express": "^5.2.1", "express-http-proxy": "^2.1.2", "express-rate-limit": "^8.2.1", - "express-session": "^1.18.2", + "express-session": "^1.19.0", + "font-awesome": "^4.7.0", "history": "5.3.0", - "isomorphic-git": "^1.35.0", - "jsonwebtoken": "^9.0.2", + "https-proxy-agent": "^7.0.6", + "isomorphic-git": "^1.36.3", + "jsonwebtoken": "^9.0.3", "load-plugin": "^6.0.3", - "lodash": "^4.17.21", + "lodash": "^4.17.23", "lusca": "^1.7.0", + "material-design-icons": "^3.0.1", "moment": "^2.30.1", "mongodb": "^5.9.2", "openid-client": "^6.8.1", @@ -51,15 +55,14 @@ "passport-activedirectory": "^1.4.0", "passport-local": "^1.0.0", "perfect-scrollbar": "^1.5.6", - "prop-types": "15.8.1", "react": "^16.14.0", "react-dom": "^16.14.0", "react-html-parser": "^2.0.2", - "react-router-dom": "6.30.2", + "react-router-dom": "6.30.3", "simple-git": "^3.30.0", - "ssh2": "^1.17.0", - "uuid": "^11.1.0", - "validator": "^13.15.23", + "ssh2": "~1.17.0", + "uuid": "^13.0.0", + "validator": "^13.15.26", "yargs": "^17.7.2" }, "bin": { @@ -67,62 +70,65 @@ "git-proxy-all": "concurrently 'npm run server' 'npm run client'" }, "devDependencies": { - "@babel/core": "^7.28.5", + "@babel/core": "^7.28.6", "@babel/preset-react": "^7.28.5", "@commitlint/cli": "^19.8.1", "@commitlint/config-conventional": "^19.8.1", - "@eslint/compat": "^2.0.0", - "@eslint/js": "^9.39.1", - "@eslint/json": "^0.14.0", + "@eslint/compat": "^2.0.2", + "@eslint/js": "^9.39.2", + "@eslint/json": "^1.0.1", "@types/activedirectory2": "^1.2.6", "@types/cors": "^2.8.19", "@types/domutils": "^2.1.0", - "@types/express": "^5.0.5", + "@types/express": "^5.0.6", "@types/express-http-proxy": "^1.6.7", "@types/express-session": "^1.18.2", "@types/jsonwebtoken": "^9.0.10", - "@types/lodash": "^4.17.20", + "@types/lodash": "^4.17.23", "@types/lusca": "^1.7.5", - "@types/node": "^22.19.1", + "@types/node": "^22.19.7", "@types/passport": "^1.0.17", "@types/passport-local": "^1.0.38", "@types/react-dom": "^17.0.26", "@types/react-html-parser": "^2.0.7", "@types/ssh2": "^1.15.5", "@types/supertest": "^6.0.3", - "@types/validator": "^13.15.9", + "@types/validator": "^13.15.10", "@types/yargs": "^17.0.35", - "@vitejs/plugin-react": "^5.1.1", + "@vitejs/plugin-react": "^5.1.2", "@vitest/coverage-v8": "^3.2.4", - "cypress": "^15.6.0", - "eslint": "^9.39.1", + "c8": "^11.0.0", + "cross-env": "^10.1.0", + "cypress": "^15.9.0", + "eslint": "^9.39.2", "eslint-config-prettier": "^10.1.8", - "eslint-plugin-cypress": "^5.2.0", + "eslint-plugin-cypress": "^5.2.1", + "eslint-plugin-license-header": "^0.9.0", "eslint-plugin-react": "^7.37.5", - "fast-check": "^4.3.0", + "fast-check": "^4.5.3", "globals": "^16.5.0", "husky": "^9.1.7", - "lint-staged": "^16.2.6", + "lint-staged": "^17.0.5", "nyc": "^17.1.0", - "prettier": "^3.6.2", + "prettier": "^3.8.1", "quicktype": "^23.2.6", - "supertest": "^7.1.4", + "supertest": "^7.2.2", "ts-node": "^10.9.2", - "tsx": "^4.20.6", + "tsx": "^4.21.0", "typescript": "^5.9.3", - "typescript-eslint": "^8.46.4", - "vite": "^7.1.9", + "typescript-eslint": "^8.54.0", + "vite": "^7.3.1", "vite-tsconfig-paths": "^5.1.4", "vitest": "^3.2.4" }, "engines": { - "node": ">=20.19.2" + "node": ">=22.13.1 || >=24.0.0" }, "optionalDependencies": { - "@esbuild/darwin-arm64": "^0.27.0", - "@esbuild/darwin-x64": "^0.27.0", - "@esbuild/linux-x64": "0.27.0", - "@esbuild/win32-x64": "0.27.0" + "@esbuild/darwin-arm64": "^0.27.2", + "@esbuild/darwin-x64": "^0.27.2", + "@esbuild/linux-x64": "0.27.2", + "@esbuild/win32-x64": "0.27.2" } }, "node_modules/@finos/git-proxy": { diff --git a/test/processors/checkCommitMessages.test.ts b/test/processors/checkMessages.test.ts similarity index 87% rename from test/processors/checkCommitMessages.test.ts rename to test/processors/checkMessages.test.ts index 1c122baf2..2d2e3751a 100644 --- a/test/processors/checkCommitMessages.test.ts +++ b/test/processors/checkMessages.test.ts @@ -16,7 +16,7 @@ import { Request } from 'express'; import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; -import { exec } from '../../src/proxy/processors/push-action/checkCommitMessages'; +import { exec } from '../../src/proxy/processors/push-action/checkMessages'; import { Action } from '../../src/proxy/actions'; import * as configModule from '../../src/config'; import { SAMPLE_COMMIT } from '../../src/proxy/processors/constants'; @@ -29,7 +29,7 @@ vi.mock('../../src/config', async (importOriginal) => { }; }); -describe('checkCommitMessages', () => { +describe('checkMessages', () => { let action: Action; let req: Request; let mockCommitConfig: any; @@ -63,7 +63,7 @@ describe('checkCommitMessages', () => { const result = await exec(req, action); expect(result.steps[0].error).toBe(true); - expect(result.steps[0].logs).toContain('checkCommitMessages - No commit message included.'); + expect(result.steps[0].logs).toContain('checkMessages - No commit message included.'); }); it('should block null commit messages', async () => { @@ -89,7 +89,7 @@ describe('checkCommitMessages', () => { expect(result.steps[0].error).toBe(true); expect(result.steps[0].logs).toContain( - 'checkCommitMessages - A non-string value has been captured for the commit message.', + 'checkMessages - A non-string value has been captured for the commit message.', ); }); @@ -120,7 +120,7 @@ describe('checkCommitMessages', () => { expect(result.steps[0].error).toBe(true); expect(result.steps[0].logs).toContain( - 'checkCommitMessages - Commit message is blocked via configured literals/patterns.', + 'checkMessages - Commit message is blocked via configured literals/patterns.', ); }); @@ -247,7 +247,7 @@ describe('checkCommitMessages', () => { expect(result.steps[0].error).toBe(false); expect(result.steps[0].logs).toContain( - 'checkCommitMessages - The following commit messages are legal: fix: resolve bug in user authentication', + 'checkMessages - The following messages are legal: fix: resolve bug in user authentication', ); }); @@ -354,7 +354,7 @@ describe('checkCommitMessages', () => { // first log is the "push blocked" message expect(step.logs[1]).toContain( - 'checkCommitMessages - The following commit messages are illegal: Add password', + 'checkMessages - The following messages are illegal: Add password', ); }); @@ -449,17 +449,17 @@ describe('checkCommitMessages', () => { describe('Function properties', () => { it('should have displayName property', () => { - expect(exec.displayName).toBe('checkCommitMessages.exec'); + expect(exec.displayName).toBe('checkMessages.exec'); }); }); describe('Step management', () => { - it('should create a step named "checkCommitMessages"', async () => { + it('should create a step named "checkMessages"', async () => { action.commitData = [{ ...SAMPLE_COMMIT, message: 'fix: bug' }]; const result = await exec(req, action); - expect(result.steps[0].stepName).toBe('checkCommitMessages'); + expect(result.steps[0].stepName).toBe('checkMessages'); }); it('should add step to action', async () => { @@ -490,5 +490,50 @@ describe('checkCommitMessages', () => { expect(result.steps[0].error).toBe(false); }); }); + + describe('Tag messages', () => { + it('should block illegal tag messages', async () => { + action.commitData = []; + action.tagData = [ + { type: 'commit', tagName: 'v1.0.0', tagger: 'Test', message: 'Release with password' }, + ]; + + const result = await exec(req, action); + + expect(result.steps[0].error).toBe(true); + expect(result.steps[0].errorMessage).toContain('Release with password'); + }); + + it('should allow valid tag messages', async () => { + action.commitData = []; + action.tagData = [ + { type: 'commit', tagName: 'v1.0.0', tagger: 'Test', message: 'Release v1.0.0' }, + ]; + + const result = await exec(req, action); + + expect(result.steps[0].error).toBe(false); + }); + + it('should validate both commit and tag messages together', async () => { + action.commitData = [{ ...SAMPLE_COMMIT, message: 'feat: valid commit' }]; + action.tagData = [ + { type: 'commit', tagName: 'v1.0.0', tagger: 'Test', message: 'tag with secret' }, + ]; + + const result = await exec(req, action); + + expect(result.steps[0].error).toBe(true); + }); + + it('should handle undefined tagData', async () => { + action.commitData = [{ ...SAMPLE_COMMIT, message: 'fix: bug' }]; + action.tagData = undefined; + + const result = await exec(req, action); + + expect(result.steps[0].error).toBe(false); + }); + }); }); }); diff --git a/test/pushUtils.test.ts b/test/pushUtils.test.ts new file mode 100644 index 000000000..e1264666a --- /dev/null +++ b/test/pushUtils.test.ts @@ -0,0 +1,371 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from 'vitest'; +import { + isTagPush, + getDisplayTimestamp, + getTagName, + getRefToShow, + getShaOrTag, + getCommitterOrTagger, + getAuthor, + getAuthorEmail, + getMessage, + getCommitCount, + getRepoFullName, + getGitHubUrl, + isValidValue, +} from '../src/ui/utils/pushUtils'; + +describe('pushUtils', () => { + const mockCommitData = [ + { + commitTs: 1640995200, // 2022-01-01 00:00:00 + commitTimestamp: 1640995200, + message: 'feat: add new feature', + committer: 'john-doe', + author: 'jane-smith', + authorEmail: 'jane@example.com', + }, + ]; + + const mockTagData = [ + { + tagName: 'v1.0.0', + type: 'annotated', + tagger: 'release-bot', + message: 'Release version 1.0.0', + timestamp: 1640995300, // 2022-01-01 00:01:40 + }, + ]; + + const mockCommitPush = { + id: 'push-1', + repo: 'test-repo.git', + branch: 'refs/heads/main', + commitTo: '1234567890abcdef', + commitData: mockCommitData, + } as any; + + const mockTagPush = { + id: 'push-2', + repo: 'test-repo.git', + branch: 'refs/heads/main', + tags: ['refs/tags/v1.0.0'], + tagData: mockTagData, + user: 'release-bot', + commitTo: '1234567890abcdef', + commitData: mockCommitData, + } as any; + + describe('isTagPush', () => { + it('returns true for tag push with tag data', () => { + expect(isTagPush(mockTagPush)).toBe(true); + }); + + it('returns false for regular commit push', () => { + expect(isTagPush(mockCommitPush)).toBe(false); + }); + + it('returns false for tag push without tagData', () => { + const pushWithoutTagData = { ...mockTagPush, tagData: [] }; + expect(isTagPush(pushWithoutTagData)).toBe(false); + }); + + it('returns false for undefined push data', () => { + expect(isTagPush(undefined as any)).toBe(false); + }); + }); + + describe('getDisplayTimestamp', () => { + it('returns tag timestamp when tagData is passed', () => { + const result = getDisplayTimestamp(mockTagData[0] as any); + expect(result).toContain('2022'); + }); + + it('returns commit timestamp when commitData is passed', () => { + const result = getDisplayTimestamp(mockCommitData[0] as any); + expect(result).toContain('2022'); + }); + + it('returns N/A when null', () => { + const result = getDisplayTimestamp(null); + expect(result).toBe('N/A'); + }); + + it('returns N/A when undefined', () => { + const result = getDisplayTimestamp(undefined); + expect(result).toBe('N/A'); + }); + + it('prefers commitTimestamp over commitTs', () => { + const commitWithBothTimestamps = { + commitTs: 1640995100, + commitTimestamp: 1640995200, + }; + const result = getDisplayTimestamp(commitWithBothTimestamps as any); + expect(result).toContain('2022'); + }); + }); + + describe('getTagName', () => { + it('extracts tag name from refs/tags/ reference', () => { + expect(getTagName(['refs/tags/v1.0.0'])).toBe('v1.0.0'); + }); + + it('handles tag name without refs/tags/ prefix', () => { + expect(getTagName(['v1.0.0'])).toBe('v1.0.0'); + }); + + it('returns empty string for undefined input', () => { + expect(getTagName(undefined)).toBe(''); + }); + + it('returns empty string for null input', () => { + expect(getTagName(null as any)).toBe(''); + }); + + it('returns empty string for empty array', () => { + expect(getTagName([])).toBe(''); + }); + + it('handles complex tag names', () => { + expect(getTagName(['refs/tags/v1.0.0-beta.1+build.123'])).toBe('v1.0.0-beta.1+build.123'); + }); + + it('handles multiple tags', () => { + expect(getTagName(['refs/tags/v1.0.0', 'refs/tags/v2.0.0'])).toBe('v1.0.0, v2.0.0'); + }); + }); + + describe('getRefToShow', () => { + it('returns tag name for tag push', () => { + expect(getRefToShow(mockTagPush)).toBe('v1.0.0'); + }); + + it('returns branch name for commit push', () => { + expect(getRefToShow(mockCommitPush)).toBe('main'); + }); + }); + + describe('getShaOrTag', () => { + it('returns tag name for tag push', () => { + expect(getShaOrTag(mockTagPush)).toBe('v1.0.0'); + }); + + it('returns shortened SHA for commit push', () => { + expect(getShaOrTag(mockCommitPush)).toBe('12345678'); + }); + + it('handles invalid commitTo gracefully', () => { + const pushWithInvalidCommit = { ...mockCommitPush, commitTo: null }; + expect(getShaOrTag(pushWithInvalidCommit)).toBe('N/A'); + }); + + it('handles non-string commitTo', () => { + const pushWithInvalidCommit = { ...mockCommitPush, commitTo: 123 }; + expect(getShaOrTag(pushWithInvalidCommit)).toBe('N/A'); + }); + }); + + describe('getCommitterOrTagger', () => { + it('returns tagger for tag push', () => { + expect(getCommitterOrTagger(mockTagPush)).toBe('release-bot'); + }); + + it('returns committer for commit push', () => { + expect(getCommitterOrTagger(mockCommitPush)).toBe('john-doe'); + }); + + it('returns N/A for empty commitData', () => { + const pushWithEmptyCommits = { ...mockCommitPush, commitData: [] }; + expect(getCommitterOrTagger(pushWithEmptyCommits)).toBe('N/A'); + }); + + it('returns N/A for invalid commitData', () => { + const pushWithInvalidCommits = { ...mockCommitPush, commitData: null }; + expect(getCommitterOrTagger(pushWithInvalidCommits)).toBe('N/A'); + }); + }); + + describe('getAuthor', () => { + it('returns tagger for tag push', () => { + expect(getAuthor(mockTagPush)).toBe('release-bot'); + }); + + it('returns author for commit push', () => { + expect(getAuthor(mockCommitPush)).toBe('jane-smith'); + }); + + it('returns N/A when author is missing', () => { + const pushWithoutAuthor = { + ...mockCommitPush, + commitData: [{ ...mockCommitData[0], author: undefined }], + }; + expect(getAuthor(pushWithoutAuthor)).toBe('N/A'); + }); + }); + + describe('getAuthorEmail', () => { + it('returns N/A for tag push', () => { + expect(getAuthorEmail(mockTagPush)).toBe('N/A'); + }); + + it('returns author email for commit push', () => { + expect(getAuthorEmail(mockCommitPush)).toBe('jane@example.com'); + }); + + it('returns N/A when email is missing', () => { + const pushWithoutEmail = { + ...mockCommitPush, + commitData: [{ ...mockCommitData[0], authorEmail: undefined }], + }; + expect(getAuthorEmail(pushWithoutEmail)).toBe('N/A'); + }); + }); + + describe('getMessage', () => { + it('returns tag message for tag push', () => { + expect(getMessage(mockTagPush)).toBe('Release version 1.0.0'); + }); + + it('returns commit message for commit push', () => { + expect(getMessage(mockCommitPush)).toBe('feat: add new feature'); + }); + + it('falls back to commit message for tag push without tag message', () => { + const tagPushWithoutMessage = { + ...mockTagPush, + tagData: [{ ...mockTagData[0], message: undefined }], + }; + expect(getMessage(tagPushWithoutMessage)).toBe('feat: add new feature'); + }); + + it('returns empty string for tag push without any message', () => { + const tagPushWithoutAnyMessage = { + ...mockTagPush, + tagData: [{ ...mockTagData[0], message: undefined }], + commitData: [{ ...mockCommitData[0], message: undefined }], + }; + expect(getMessage(tagPushWithoutAnyMessage)).toBe(''); + }); + }); + + describe('getCommitCount', () => { + it('returns commit count', () => { + expect(getCommitCount(mockCommitPush)).toBe(1); + }); + + it('returns 0 for empty commitData', () => { + const pushWithoutCommits = { ...mockCommitPush, commitData: [] }; + expect(getCommitCount(pushWithoutCommits)).toBe(0); + }); + + it('returns 0 for undefined commitData', () => { + const pushWithoutCommits = { ...mockCommitPush, commitData: undefined }; + expect(getCommitCount(pushWithoutCommits)).toBe(0); + }); + }); + + describe('getRepoFullName', () => { + it('removes .git suffix', () => { + expect(getRepoFullName('test-repo.git')).toBe('test-repo'); + }); + + it('handles repo without .git suffix', () => { + expect(getRepoFullName('test-repo')).toBe('test-repo'); + }); + }); + + describe('getGitHubUrl', () => { + it('generates correct repo URL', () => { + expect(getGitHubUrl.repo('owner/repo')).toBe('https://github.com/owner/repo'); + }); + + it('generates correct commit URL', () => { + expect(getGitHubUrl.commit('owner/repo', 'abc123')).toBe( + 'https://github.com/owner/repo/commit/abc123', + ); + }); + + it('generates correct branch URL', () => { + expect(getGitHubUrl.branch('owner/repo', 'main')).toBe( + 'https://github.com/owner/repo/tree/main', + ); + }); + + it('generates correct tag URL', () => { + expect(getGitHubUrl.tag('owner/repo', 'v1.0.0')).toBe( + 'https://github.com/owner/repo/releases/tag/v1.0.0', + ); + }); + + it('generates correct user URL', () => { + expect(getGitHubUrl.user('username')).toBe('https://github.com/username'); + }); + }); + + describe('isValidValue', () => { + it('returns true for valid string', () => { + expect(isValidValue('valid')).toBe(true); + }); + + it('returns false for N/A', () => { + expect(isValidValue('N/A')).toBe(false); + }); + + it('returns false for empty string', () => { + expect(isValidValue('')).toBe(false); + }); + + it('returns false for undefined', () => { + expect(isValidValue(undefined as any)).toBe(false); + }); + + it('returns false for null', () => { + expect(isValidValue(null as any)).toBe(false); + }); + }); + + describe('edge cases and error handling', () => { + it('handles malformed tag reference in getTagName', () => { + expect(() => getTagName(['malformed-ref'])).not.toThrow(); + expect(getTagName(['malformed-ref'])).toBe('malformed-ref'); + }); + + it('handles missing properties gracefully', () => { + const incompletePush = { + id: 'incomplete', + commitData: [], + } as any; + + expect(() => getCommitterOrTagger(incompletePush)).not.toThrow(); + expect(() => getAuthor(incompletePush)).not.toThrow(); + expect(() => getMessage(incompletePush)).not.toThrow(); + expect(() => getCommitCount(incompletePush)).not.toThrow(); + }); + + it('handles non-array commitData', () => { + const pushWithInvalidCommits = { + ...mockCommitPush, + commitData: 'not-an-array', + }; + + expect(getCommitterOrTagger(pushWithInvalidCommits)).toBe('N/A'); + }); + }); +}); diff --git a/test/tagPushIntegration.test.ts b/test/tagPushIntegration.test.ts new file mode 100644 index 000000000..8b3e74fe7 --- /dev/null +++ b/test/tagPushIntegration.test.ts @@ -0,0 +1,246 @@ +/** + * Copyright 2026 GitProxy Contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { describe, it, expect } from 'vitest'; +import { + isTagPush, + getDisplayTimestamp, + getRefToShow, + getShaOrTag, + getCommitterOrTagger, + getMessage, + getRepoFullName, + getGitHubUrl, +} from '../src/ui/utils/pushUtils'; + +describe('Tag Push Integration', () => { + describe('complete tag push workflow', () => { + const fullTagPush = { + id: 'tag-push-123', + repo: 'finos/git-proxy.git', + branch: 'refs/heads/main', + tags: ['refs/tags/v2.1.0'], + user: 'release-manager', + commitFrom: '0000000000000000000000000000000000000000', + commitTo: 'abcdef1234567890abcdef1234567890abcdef12', + timestamp: '2024-01-15T10:30:00Z', + tagData: [ + { + tagName: 'v2.1.0', + type: 'annotated', + tagger: 'release-manager', + message: + 'Release version 2.1.0\n\nThis release includes:\n- New tag push support\n- Improved UI components\n- Better error handling', + timestamp: 1705317000, + }, + ], + commitData: [ + { + commitTs: 1705316700, + commitTimestamp: 1705316700, + message: 'feat: implement tag push support', + committer: 'developer-1', + author: 'developer-1', + authorEmail: 'dev1@finos.org', + }, + { + commitTs: 1705316400, + commitTimestamp: 1705316400, + message: 'docs: update README with tag instructions', + committer: 'developer-2', + author: 'developer-2', + authorEmail: 'dev2@finos.org', + }, + ], + diff: { content: '+++ new tag support implementation' }, + } as any; + + it('correctly identifies as tag push', () => { + expect(isTagPush(fullTagPush)).toBe(true); + }); + + it('generates correct display data for table view', () => { + expect(getRepoFullName(fullTagPush.repo)).toBe('finos/git-proxy'); + expect(getRefToShow(fullTagPush)).toBe('v2.1.0'); + expect(getShaOrTag(fullTagPush)).toBe('v2.1.0'); + expect(getCommitterOrTagger(fullTagPush)).toBe('release-manager'); + expect(getMessage(fullTagPush)).toContain('Release version 2.1.0'); + }); + + it('generates correct GitHub URLs for tag push', () => { + const repoName = getRepoFullName(fullTagPush.repo); + expect(getGitHubUrl.repo(repoName)).toBe('https://github.com/finos/git-proxy'); + expect(getGitHubUrl.tag(repoName, 'v2.1.0')).toBe( + 'https://github.com/finos/git-proxy/releases/tag/v2.1.0', + ); + expect(getGitHubUrl.user('release-manager')).toBe('https://github.com/release-manager'); + }); + + it('uses tag timestamp over commit timestamp', () => { + const displayTime = getDisplayTimestamp(fullTagPush.tagData[0]); + expect(displayTime).toContain('2024'); + expect(displayTime).toContain('Jan 15'); + }); + + it('handles search functionality properly', () => { + const searchableFields = { + repoName: getRepoFullName(fullTagPush.repo).toLowerCase(), + message: getMessage(fullTagPush).toLowerCase(), + tagName: fullTagPush.tags[0].replace('refs/tags/', '').toLowerCase(), + }; + expect(searchableFields.repoName).toContain('finos'); + expect(searchableFields.message).toContain('release'); + expect(searchableFields.tagName).toBe('v2.1.0'); + }); + }); + + describe('lightweight tag push workflow', () => { + const lightweightTagPush = { + id: 'lightweight-tag-123', + repo: 'example/repo.git', + tags: ['refs/tags/quick-fix'], + user: 'hotfix-user', + commitTo: 'fedcba0987654321fedcba0987654321fedcba09', + tagData: [{ tagName: 'quick-fix', type: 'lightweight', tagger: 'hotfix-user', message: '' }], + commitData: [ + { + commitTimestamp: 1705317300, + message: 'fix: critical security patch', + committer: 'hotfix-user', + author: 'security-team', + authorEmail: 'security@example.com', + }, + ], + } as any; + + it('handles lightweight tags correctly', () => { + expect(isTagPush(lightweightTagPush)).toBe(true); + expect(getRefToShow(lightweightTagPush)).toBe('quick-fix'); + expect(getShaOrTag(lightweightTagPush)).toBe('quick-fix'); + }); + + it('falls back to commit message for lightweight tags', () => { + expect(getMessage(lightweightTagPush)).toBe('fix: critical security patch'); + }); + }); + + describe('edge cases in tag push handling', () => { + it('handles tag push with missing tagData gracefully', () => { + const incompleteTagPush = { + id: 'incomplete-tag', + repo: 'test/repo.git', + tags: ['refs/tags/broken-tag'], + user: 'test-user', + commitData: [], + tagData: [], + } as any; + expect(isTagPush(incompleteTagPush)).toBe(false); + expect(getCommitterOrTagger(incompleteTagPush)).toBe('N/A'); + }); + + it('handles tag push with malformed tag reference', () => { + const malformedTagPush = { + id: 'malformed-tag', + repo: 'test/repo.git', + tags: ['malformed-tag-ref'], + tagData: [ + { tagName: 'v1.0.0', type: 'annotated', tagger: 'test-user', message: 'Test release' }, + ], + commitData: [ + { commitTimestamp: 1705317000, message: 'test commit', committer: 'test-user' }, + ], + } as any; + expect(isTagPush(malformedTagPush)).toBe(true); + expect(() => getRefToShow(malformedTagPush)).not.toThrow(); + expect(getRefToShow(malformedTagPush)).toBe('malformed-tag-ref'); + }); + + it('handles complex tag names with special characters', () => { + const complexTagPush = { + id: 'complex-tag', + repo: 'test/repo.git', + tags: ['refs/tags/v1.0.0-beta.1+build.123'], + tagData: [ + { + tagName: 'v1.0.0-beta.1+build.123', + type: 'annotated', + tagger: 'ci-bot', + message: 'Pre-release', + }, + ], + commitData: [ + { + commitTimestamp: 1705317000, + message: 'chore: prepare beta release', + committer: 'ci-bot', + }, + ], + } as any; + expect(isTagPush(complexTagPush)).toBe(true); + expect(getRefToShow(complexTagPush)).toBe('v1.0.0-beta.1+build.123'); + expect(getShaOrTag(complexTagPush)).toBe('v1.0.0-beta.1+build.123'); + }); + }); + + describe('comparison with regular commit push', () => { + const regularCommitPush = { + id: 'commit-push-456', + repo: 'finos/git-proxy.git', + branch: 'refs/heads/feature-branch', + commitFrom: '1111111111111111111111111111111111111111', + commitTo: '2222222222222222222222222222222222222222', + commitData: [ + { + commitTimestamp: 1705317000, + message: 'feat: add new feature', + committer: 'feature-dev', + author: 'feature-dev', + authorEmail: 'dev@finos.org', + }, + ], + } as any; + + it('differentiates between tag and commit pushes', () => { + const tagPush = { + tags: ['refs/tags/v1.0.0'], + tagData: [{ tagName: 'v1.0.0' }], + commitData: [], + } as any; + expect(isTagPush(tagPush)).toBe(true); + expect(isTagPush(regularCommitPush)).toBe(false); + }); + + it('generates different URLs for tag vs commit pushes', () => { + const repoName = 'finos/git-proxy'; + expect(getGitHubUrl.tag(repoName, 'v1.0.0')).toContain('/releases/tag/'); + expect(getGitHubUrl.commit(repoName, '2222222222222222222222222222222222222222')).toContain( + '/commit/', + ); + expect(getGitHubUrl.branch(repoName, 'feature-branch')).toContain('/tree/'); + }); + + it('shows different committer/author behavior', () => { + const tagPushWithUser = { + tags: ['refs/tags/v1.0.0'], + tagData: [{ tagName: 'v1.0.0' }], + user: 'tag-creator', + commitData: [{ committer: 'original-committer' }], + } as any; + expect(getCommitterOrTagger(tagPushWithUser)).toBe('tag-creator'); + expect(getCommitterOrTagger(regularCommitPush)).toBe('feature-dev'); + }); + }); +}); diff --git a/test/testParsePush.test.ts b/test/testParsePush.test.ts index cbf26410b..8ca551011 100644 --- a/test/testParsePush.test.ts +++ b/test/testParsePush.test.ts @@ -25,7 +25,8 @@ import { getCommitData, getContents, getPackMeta, -} from '../src/proxy/processors/push-action/parsePush'; + getTagData, +} from '../src/proxy/processors/pre-processor/parsePush'; import { parsePacketLines } from '../src/proxy/processors/pktLineParser'; import { EMPTY_COMMIT_HASH, FLUSH_PACKET, PACK_SIGNATURE } from '../src/proxy/processors/constants'; @@ -132,8 +133,12 @@ const TEST_MULTI_OBJ_COMMIT_CONTENT = [ { type: 3, content: 'not really a blob\n', message: 'not really a blob\n' }, // TODO: update this with a more realistic example { type: 2, content: 'not really a tree\n', message: 'not really a tree\n' }, - // TODO: update this with a more realistic example - { type: 4, content: 'not really a tag\n', message: 'not really a tag\n' }, + { + type: 4, + content: + 'object 1234567890abcdef1234567890abcdef12345678\ntype commit\ntag test-tag\ntagger Test Tagger 1756487400 +0100\n\nTest tag message', + message: 'Test tag message', + }, { type: 6, baseOffset: 997, @@ -323,6 +328,47 @@ function createEmptyPackBuffer() { return Buffer.concat([header, checksum]); } +/** + * Creates a PACK buffer containing a single tag object for testing. + * @param {string} tagContent - Content of the tag object. + * @return {Buffer} - The generated PACK buffer. + */ +function createSampleTagPackBuffer( + tagContent = 'object 1234567890abcdef1234567890abcdef12345678\ntype commit\ntag v1.0.0\ntagger Test Tagger 1234567890 +0000\n\nTag message', +): Buffer { + const header = Buffer.alloc(12); + header.write(PACK_SIGNATURE, 0, 4, 'utf-8'); + header.writeUInt32BE(2, 4); + header.writeUInt32BE(1, 8); + + const originalContent = Buffer.from(tagContent, 'utf8'); + const compressedContent = deflateSync(originalContent); + const objectHeader = encodeGitObjectHeader(4, originalContent.length); // type 4 = tag + + const packContent = Buffer.concat([objectHeader, compressedContent]); + const fullPackWithoutChecksum = Buffer.concat([header, packContent]); + const checksum = createHash('sha1').update(fullPackWithoutChecksum).digest(); + return Buffer.concat([fullPackWithoutChecksum, checksum]); +} + +function createMultiTagPackBuffer(tagContents: string[]): Buffer { + const header = Buffer.alloc(12); + header.write(PACK_SIGNATURE, 0, 4, 'utf-8'); + header.writeUInt32BE(2, 4); + header.writeUInt32BE(tagContents.length, 8); + + const objects = tagContents.map((content) => { + const original = Buffer.from(content, 'utf8'); + const compressed = deflateSync(original); + const objHeader = encodeGitObjectHeader(4, original.length); + return Buffer.concat([objHeader, compressed]); + }); + + const fullPackWithoutChecksum = Buffer.concat([header, ...objects]); + const checksum = createHash('sha1').update(fullPackWithoutChecksum).digest(); + return Buffer.concat([fullPackWithoutChecksum, checksum]); +} + describe('parsePackFile', () => { let action: any; let req: Request; @@ -330,11 +376,15 @@ describe('parsePackFile', () => { beforeEach(() => { // Mock Action and Step and spy on methods action = { + actionType: null, branch: null, + tags: null, + tagData: [], commitFrom: null, commitTo: null, commitData: [], user: null, + userEmail: null, steps: [], addStep: vi.fn(function (this: Action, step: Step) { this.steps.push(step); @@ -427,11 +477,10 @@ describe('parsePackFile', () => { const step = action.steps[0]; expect(step.stepName).toBe('parsePackFile'); expect(step.error).toBe(true); - expect(step.errorMessage).toContain('pushing to a single branch'); - expect(step.logs[0]).toContain('Invalid number of branch updates'); + expect(step.errorMessage).toContain('No ref updates found'); }); - it('should add error step if multiple ref updates found', async () => { + it('should add error step if multiple branch ref updates found', async () => { const packetLines = [ 'oldhash1 newhash1 refs/heads/main\0caps\n', 'oldhash2 newhash2 refs/heads/develop\0caps\n', @@ -443,9 +492,7 @@ describe('parsePackFile', () => { const step = action.steps[0]; expect(step.stepName).toBe('parsePackFile'); expect(step.error).toBe(true); - expect(step.errorMessage).toContain('pushing to a single branch'); - expect(step.logs[0]).toContain('Invalid number of branch updates'); - expect(step.logs[1]).toContain('Expected 1, but got 2'); + expect(step.errorMessage).toContain('push one branch at a time'); }); it('should add error step if extra part in ref update', async () => { @@ -458,8 +505,6 @@ describe('parsePackFile', () => { expect(step.stepName).toBe('parsePackFile'); expect(step.error).toBe(true); expect(step.errorMessage).toContain('Invalid ref update format'); - expect(step.logs[0]).toContain('Invalid number of parts in ref update'); - expect(step.logs[1]).toContain('Expected 3, but got 4'); }); it('should add error step if PACK data is missing', async () => { @@ -509,11 +554,13 @@ describe('parsePackFile', () => { expect(step.error).toBe(false); expect(step.errorMessage).toBeNull(); + expect(action.actionType).toBe('branch'); expect(action.branch).toBe(ref); expect(action.setCommit).toHaveBeenCalledWith(oldCommit, newCommit); expect(action.commitFrom).toBe(oldCommit); expect(action.commitTo).toBe(newCommit); expect(action.user).toBe('Test Committer'); + expect(action.userEmail).toBe('committer@example.com'); // Check parsed commit data expect(action.commitData).toHaveLength(1); @@ -562,6 +609,7 @@ describe('parsePackFile', () => { expect(step.error).toBe(false); expect(step.errorMessage).toBeNull(); + expect(action.actionType).toBe('branch'); expect(action.branch).toBe(ref); expect(action.setCommit).toHaveBeenCalledWith(oldCommit, newCommit); expect(action.commitFrom).toBe(oldCommit); @@ -605,11 +653,13 @@ describe('parsePackFile', () => { expect(step.error).toBe(false); expect(step.errorMessage).toBeNull(); + expect(action.actionType).toBe('branch'); expect(action.branch).toBe(ref); expect(action.setCommit).toHaveBeenCalledWith(oldCommit, newCommit); expect(action.commitFrom).toBe(oldCommit); expect(action.commitTo).toBe(newCommit); expect(action.user).toBe('CCCCCCCCCCC'); + expect(action.userEmail).toBe('ccccccccc@cccccccc.com'); // Check parsed commit messages only const expectedCommits = TEST_MULTI_OBJ_COMMIT_CONTENT.filter((v) => v.type === 1); @@ -947,9 +997,149 @@ describe('parsePackFile', () => { expect(step).toBeTruthy(); expect(step.error).toBe(false); + expect(action.actionType).toBe('branch'); expect(action.branch).toBe(ref); expect(action.setCommit).toHaveBeenCalledWith(EMPTY_COMMIT_HASH, newCommit); expect(action.commitData).toHaveLength(0); + expect(action.user).toBeNull(); + expect(action.userEmail).toBeNull(); + }); + + it('should successfully parse a valid tag push request', async () => { + const oldCommit = '0'.repeat(40); + const newCommit = 'c'.repeat(40); + const ref = 'refs/tags/v1.0.0'; + const packetLine = `${oldCommit} ${newCommit} ${ref}\0capabilities\n`; + + const tagContent = + 'object 1234567890abcdef1234567890abcdef12345678\n' + + 'type commit\n' + + 'tag v1.0.0\n' + + 'tagger Test Tagger 1234567890 +0000\n\n' + + 'Release v1.0.0'; + + const packBuffer = createSampleTagPackBuffer(tagContent); + req.body = Buffer.concat([createPacketLineBuffer([packetLine]), packBuffer]); + + const result = await exec(req, action); + expect(result).toBe(action); + + const step = action.steps.find((s: any) => s.stepName === 'parsePackFile'); + expect(step).toBeDefined(); + expect(step.error).toBe(false); + expect(step.errorMessage).toBeNull(); + + expect(action.tags).toEqual([ref]); + expect(action.branch).toBeNull(); + expect(action.setCommit).toHaveBeenCalledWith(oldCommit, newCommit); + expect(action.commitFrom).toBe(oldCommit); + expect(action.commitTo).toBe(newCommit); + expect(action.user).toBe('Test Tagger'); + expect(action.userEmail).toBe('tagger@example.com'); + + expect(action.tagData).toHaveLength(1); + expect(action.tagData[0].tagName).toBe('v1.0.0'); + expect(action.tagData[0].tagger).toBe('Test Tagger'); + expect(action.tagData[0].taggerEmail).toBe('tagger@example.com'); + expect(action.tagData[0].message).toBe('Release v1.0.0'); + expect(action.tagData[0].object).toBe('1234567890abcdef1234567890abcdef12345678'); + expect(action.tagData[0].type).toBe('commit'); + expect(action.commitData).toHaveLength(0); + }); + + it('should set actionType to TAG for tag refs', async () => { + const oldCommit = '0'.repeat(40); + const newCommit = 'd'.repeat(40); + const ref = 'refs/tags/v2.0.0'; + const packetLine = `${oldCommit} ${newCommit} ${ref}\0capabilities\n`; + + const tagContent = + 'object abcdef1234567890abcdef1234567890abcdef12\n' + + 'type commit\n' + + 'tag v2.0.0\n' + + 'tagger Another Tagger 9876543210 +0000\n\n' + + 'Release v2.0.0'; + + const packBuffer = createSampleTagPackBuffer(tagContent); + req.body = Buffer.concat([createPacketLineBuffer([packetLine]), packBuffer]); + + const result = await exec(req, action); + expect(result).toBe(action); + + expect(action.actionType).toBe('tag'); + expect(action.tags).toEqual([ref]); + }); + + it('should successfully parse a multi-tag push request', async () => { + const old1 = '0'.repeat(40); + const new1 = 'a'.repeat(40); + const old2 = '0'.repeat(40); + const new2 = 'b'.repeat(40); + const ref1 = 'refs/tags/v1.0.0'; + const ref2 = 'refs/tags/v2.0.0'; + const packetLines = [`${old1} ${new1} ${ref1}\0capabilities\n`, `${old2} ${new2} ${ref2}\n`]; + + const tag1Content = + 'object 1234567890abcdef1234567890abcdef12345678\n' + + 'type commit\n' + + 'tag v1.0.0\n' + + 'tagger Tagger One 1234567890 +0000\n\n' + + 'Release v1.0.0'; + const tag2Content = + 'object abcdef1234567890abcdef1234567890abcdef12\n' + + 'type commit\n' + + 'tag v2.0.0\n' + + 'tagger Tagger Two 1234567891 +0000\n\n' + + 'Release v2.0.0'; + + const packBuffer = createMultiTagPackBuffer([tag1Content, tag2Content]); + req.body = Buffer.concat([createPacketLineBuffer(packetLines), packBuffer]); + + const result = await exec(req, action); + expect(result).toBe(action); + + const step = action.steps.find((s: any) => s.stepName === 'parsePackFile'); + expect(step).toBeDefined(); + expect(step.error).toBe(false); + + expect(action.actionType).toBe('tag'); + expect(action.tags).toEqual([ref1, ref2]); + expect(action.setCommit).toHaveBeenCalledWith(old1, new1); + expect(action.tagData).toHaveLength(2); + expect(action.tagData[0].tagName).toBe('v1.0.0'); + expect(action.tagData[1].tagName).toBe('v2.0.0'); + }); + + it('should block mixed tag and branch ref updates', async () => { + const packetLines = [ + `${'a'.repeat(40)} ${'b'.repeat(40)} refs/tags/v1.0.0\0caps\n`, + `${'c'.repeat(40)} ${'d'.repeat(40)} refs/heads/main\n`, + ]; + req.body = createPacketLineBuffer(packetLines); + const result = await exec(req, action); + + expect(result).toBe(action); + const step = action.steps[0]; + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('push one branch at a time'); + }); + + it('should block lightweight (non-annotated) tag push', async () => { + const oldCommit = '0'.repeat(40); + const newCommit = 'e'.repeat(40); + const ref = 'refs/tags/v-lightweight'; + const packetLine = `${oldCommit} ${newCommit} ${ref}\0capabilities\n`; + + const emptyPackBuffer = createEmptyPackBuffer(); + req.body = Buffer.concat([createPacketLineBuffer([packetLine]), emptyPackBuffer]); + + const result = await exec(req, action); + expect(result).toBe(action); + + const step = action.steps.find((s: any) => s.stepName === 'parsePackFile'); + expect(step).toBeDefined(); + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('Lightweight (non-annotated) tags are not supported'); }); }); @@ -1216,4 +1406,75 @@ describe('parsePackFile', () => { expect(() => parsePacketLines(incompleteBuffer)).toThrow(/Invalid packet line length 0008/); }); }); + + describe('getTagData', () => { + it('should parse a valid tag object', () => { + const content = + 'object 1234567890abcdef1234567890abcdef12345678\n' + + 'type commit\n' + + 'tag v1.0.0\n' + + 'tagger Test Tagger 1234567890 +0000\n\n' + + 'Release v1.0.0'; + + const result = getTagData({ type: 4, content } as any); + + expect(result.object).toBe('1234567890abcdef1234567890abcdef12345678'); + expect(result.type).toBe('commit'); + expect(result.tagName).toBe('v1.0.0'); + expect(result.tagger).toBe('Test Tagger'); + expect(result.taggerEmail).toBe('tagger@example.com'); + expect(result.timestamp).toBe('1234567890'); + expect(result.message).toBe('Release v1.0.0'); + }); + + it('should parse a tag object with multi-line message', () => { + const content = + 'object abcdef1234567890abcdef1234567890abcdef12\n' + + 'type commit\n' + + 'tag v2.0.0\n' + + 'tagger Releaser 9876543210 +0100\n\n' + + 'Release v2.0.0\n\nThis release includes:\n- Feature A\n- Bug fix B'; + + const result = getTagData({ type: 4, content } as any); + + expect(result.tagName).toBe('v2.0.0'); + expect(result.tagger).toBe('Releaser'); + expect(result.taggerEmail).toBe('releaser@example.com'); + expect(result.message).toBe( + 'Release v2.0.0\n\nThis release includes:\n- Feature A\n- Bug fix B', + ); + }); + + it('should throw if tagger line is missing', () => { + const content = + 'object 1234567890abcdef1234567890abcdef12345678\n' + + 'type commit\n' + + 'tag v1.0.0\n\n' + + 'Release without tagger'; + + expect(() => getTagData({ type: 4, content } as any)).toThrow( + 'Invalid tag object: no tagger line', + ); + }); + + it('should throw if object line is missing', () => { + const content = + 'type commit\n' + + 'tag v1.0.0\n' + + 'tagger Test Tagger 1234567890 +0000\n\n' + + 'Message'; + + expect(() => getTagData({ type: 4, content } as any)).toThrow('Invalid tag object'); + }); + + it('should throw if tag name is missing', () => { + const content = + 'object 1234567890abcdef1234567890abcdef12345678\n' + + 'type commit\n' + + 'tagger Test Tagger 1234567890 +0000\n\n' + + 'Message'; + + expect(() => getTagData({ type: 4, content } as any)).toThrow('Invalid tag object'); + }); + }); });