From adc13628ea2bee97b82158d6512f533d62e055e8 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 11 Mar 2026 10:13:53 +0100 Subject: [PATCH 01/27] =?UTF-8?q?chore:=20fix=20eslintrc.json=20=E2=80=94?= =?UTF-8?q?=20remove=20invalid=20JS=20comments?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.json | 63 ++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .eslintrc.json 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"] +} From 62896c988ca22be9493d3b0b42246cd0cf70cd9b Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 11 Mar 2026 10:14:03 +0100 Subject: [PATCH 02/27] feat: add ActionType and RequestType enums --- src/proxy/actions/Action.ts | 22 ++++++++++++++++++++-- src/proxy/actions/index.ts | 4 ++-- 2 files changed, 22 insertions(+), 4 deletions(-) diff --git a/src/proxy/actions/Action.ts b/src/proxy/actions/Action.ts index 350239e94..30172be7a 100644 --- a/src/proxy/actions/Action.ts +++ b/src/proxy/actions/Action.ts @@ -17,13 +17,29 @@ import { processGitURLForNameAndOrg, processUrlPath } from '../routes/helper'; import { Step } from './Step'; import { Attestation, CommitData, Rejection } from '../processors/types'; +import { TagData } from '../../types/models'; + +export enum RequestType { + PUSH = 'push', + + PULL = 'pull', +} + +export enum ActionType { + COMMIT = 'commit', + + TAG = 'tag', + + BRANCH = 'branch', +} /** * Class representing a Push. */ class Action { id: string; - type: string; + type: RequestType; + actionType?: ActionType; method: string; timestamp: number; project: string; @@ -53,6 +69,8 @@ class Action { rejection?: Rejection; lastStep?: Step; proxyGitPath?: string; + tag?: string; + tagData?: TagData[]; newIdxFiles?: string[]; /** @@ -63,7 +81,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..5914fbbfb 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, ActionType } from './Action'; import { Step } from './Step'; -export { Action, Step }; +export { Action, Step, RequestType, ActionType }; From b711fb59c612c81b9be978040e3a39433a325080 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 11 Mar 2026 10:14:12 +0100 Subject: [PATCH 03/27] feat: add tag push parsing (parseTag, parsePush, constants) --- src/proxy/processors/constants.ts | 2 + .../processors/pre-processor/parseAction.ts | 10 +- src/proxy/processors/push-action/parsePush.ts | 92 ++++++++++++++++--- 3 files changed, 87 insertions(+), 17 deletions(-) diff --git a/src/proxy/processors/constants.ts b/src/proxy/processors/constants.ts index 6447b594f..94ff2fc74 100644 --- a/src/proxy/processors/constants.ts +++ b/src/proxy/processors/constants.ts @@ -15,8 +15,10 @@ */ 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; diff --git a/src/proxy/processors/pre-processor/parseAction.ts b/src/proxy/processors/pre-processor/parseAction.ts index 9be786a3f..d54f279a1 100644 --- a/src/proxy/processors/pre-processor/parseAction.ts +++ b/src/proxy/processors/pre-processor/parseAction.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Action } from '../../actions'; +import { Action, RequestType } from '../../actions'; import { processUrlPath } from '../../routes/helper'; import * as db from '../../../db'; @@ -25,14 +25,14 @@ const exec = async (req: { }) => { const id = Date.now(); const timestamp = id; - let type = 'default'; + let type: RequestType | string = '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://:// @@ -54,7 +54,7 @@ const exec = async (req: { ); } - return new Action(id.toString(), type, req.method, timestamp, url); + return new Action(id.toString(), type as RequestType, req.method, timestamp, url); }; exec.displayName = 'parseAction.exec'; diff --git a/src/proxy/processors/push-action/parsePush.ts b/src/proxy/processors/push-action/parsePush.ts index f27b736ca..e26ad30df 100644 --- a/src/proxy/processors/push-action/parsePush.ts +++ b/src/proxy/processors/push-action/parsePush.ts @@ -14,17 +14,20 @@ * limitations under the License. */ -import { Action, Step } from '../../actions'; +import { Action, Step, ActionType } from '../../actions'; import fs from 'fs'; import lod from 'lodash'; import { createInflate } from 'zlib'; import { CommitContent, CommitData, CommitHeader, PackMeta, PersonLine } from '../types'; +import { TagData } from '../../../types/models'; import { BRANCH_PREFIX, + TAG_PREFIX, EMPTY_COMMIT_HASH, PACK_SIGNATURE, PACKET_SIZE, GIT_OBJECT_TYPE_COMMIT, + GIT_OBJECT_TYPE_TAG, } from '../constants'; const dir = './.tmp/'; @@ -54,13 +57,13 @@ async function exec(req: any, action: Action): Promise { throw new Error('No body found in request'); } const [packetLines, packDataOffset] = parsePacketLines(req.body); - const refUpdates = packetLines.filter((line) => line.includes(BRANCH_PREFIX)); + const refUpdates = packetLines.filter((line) => line.includes('refs/')); if (refUpdates.length !== 1) { - step.log('Invalid number of branch updates.'); + step.log('Invalid number of ref updates.'); step.log(`Expected 1, but got ${refUpdates.length}`); 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 (multiple tags and/or branches) are not supported yet. Please push one ref at a time.', ); } else { console.log(`refUpdates: ${JSON.stringify(refUpdates, null, 2)}`); @@ -78,7 +81,21 @@ async function exec(req: any, action: Action): Promise { // 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(); + const refName = ref.replace(/\0.*/, '').trim(); + const isTag = refName.startsWith(TAG_PREFIX); + const isBranch = refName.startsWith(BRANCH_PREFIX); + + action.branch = isBranch ? refName : undefined; + action.tag = isTag ? refName : undefined; + + // Set actionType based on what type of push this is + if (isTag) { + action.actionType = ActionType.TAG; + } else if (isBranch) { + action.actionType = ActionType.BRANCH; + } else { + action.actionType = ActionType.COMMIT; + } // Note this will change the action.id to be based on the commits action.setCommit(oldCommit, newCommit); @@ -99,19 +116,32 @@ async function exec(req: any, action: Action): Promise { const [meta, contentBuff] = getPackMeta(buf); const contents = await getContents(contentBuff, meta.entries); - action.commitData = getCommitData(contents as any); + const ParsedObjects = { + commits: [] as CommitData[], + tags: [] as TagData[], + }; - if (action.commitData.length === 0) { - step.log('No commit data found when parsing push.'); - } else { + for (const obj of contents) { + if (obj.type === GIT_OBJECT_TYPE_COMMIT) ParsedObjects.commits.push(...getCommitData([obj])); + else if (obj.type === GIT_OBJECT_TYPE_TAG) ParsedObjects.tags.push(parseTag(obj)); + } + + action.commitData = ParsedObjects.commits; + action.tagData = ParsedObjects.tags; + + if (action.commitData.length) { if (action.commitFrom === EMPTY_COMMIT_HASH) { action.commitFrom = action.commitData[action.commitData.length - 1].parent; } - const { committer, committerEmail } = action.commitData[action.commitData.length - 1]; console.log(`Push Request received from user ${committer} with email ${committerEmail}`); action.user = committer; action.userEmail = committerEmail; + } else if (action.tagData?.length) { + action.user = action.tagData.at(-1)!.tagger; + action.userEmail = action.tagData.at(-1)!.taggerEmail; + } else { + step.log('No commit data found when parsing push.'); } step.content = { @@ -119,7 +149,7 @@ async function exec(req: any, action: Action): Promise { }; } catch (e: any) { step.setError( - `Unable to parse push. Please contact an administrator for support: ${e.toString('utf-8')}`, + `Unable to parse push. Please contact an administrator for support: ${e.message || e.toString()}`, ); } finally { action.addStep(step); @@ -127,6 +157,44 @@ async function exec(req: any, action: Action): Promise { return action; } +function parseTag(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. * @@ -587,4 +655,4 @@ const parsePacketLines = (buffer: Buffer): [string[], number] => { exec.displayName = 'parsePush.exec'; -export { exec, getCommitData, getContents, getPackMeta, parsePacketLines }; +export { exec, getCommitData, getContents, getPackMeta, parsePacketLines, parseTag }; From 83a355007f90614df24fca567ced34f59753aeba Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 11 Mar 2026 10:14:21 +0100 Subject: [PATCH 04/27] feat: add tag push chain and audit processor --- src/proxy/chain.ts | 64 ++++++++++++++++---- src/proxy/processors/post-processor/audit.ts | 4 +- 2 files changed, 54 insertions(+), 14 deletions(-) diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index 4e787af23..e788b614e 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -15,12 +15,11 @@ */ import { PluginLoader } from '../plugin'; -import { Action } from './actions'; +import { Action, RequestType, ActionType } from './actions'; import * as proc from './processors'; import { attemptAutoApproval, attemptAutoRejection } from './actions/autoActions'; -const pushActionChain: ((req: any, action: Action) => Promise)[] = [ - proc.push.parsePush, +const branchPushChain: ((req: any, action: Action) => Promise)[] = [ proc.push.checkEmptyBranch, proc.push.checkRepoInAuthorisedList, proc.push.checkCommitMessages, @@ -37,6 +36,17 @@ const pushActionChain: ((req: any, action: Action) => Promise)[] = [ proc.push.blockForAuth, ]; +const tagPushChain: ((req: any, action: Action) => Promise)[] = [ + proc.push.checkRepoInAuthorisedList, + proc.push.checkUserPushPermission, + proc.push.checkIfWaitingAuth, + proc.push.pullRemote, + proc.push.writePack, + proc.push.preReceive, + // TODO: implement tag message validation? + proc.push.blockForAuth, +]; + const pullActionChain: ((req: any, action: Action) => Promise)[] = [ proc.push.checkRepoInAuthorisedList, ]; @@ -52,9 +62,16 @@ export const executeChain = async (req: any, res: any): Promise => { let checkoutCleanUpRequired = false; try { + // 1) Initialize basic action fields action = await proc.pre.parseAction(req); + // 2) Parse the push payload first to detect tags/branches + if (action.type === RequestType.PUSH) { + action = await proc.push.parsePush(req, action); + } + // 3) Select the correct chain now that action.actionType is set const actionFns = await getChain(action); + // 4) Execute each step in the selected chain for (const fn of actionFns) { action = await fn(req, action); if (!action.continue() || action.allowPush) { @@ -93,6 +110,22 @@ export const executeChain = async (req: any, res: any): Promise => { */ let chainPluginLoader: PluginLoader; +/** + * Selects the appropriate push chain based on action type + * @param {Action} action The action to select a chain for + * @return {Array} The appropriate push chain + */ +const getPushChain = (action: Action): ((req: any, action: Action) => Promise)[] => { + switch (action.actionType) { + case ActionType.TAG: + return tagPushChain; + case ActionType.BRANCH: + case ActionType.COMMIT: + default: + return branchPushChain; + } +}; + export const getChain = async ( action: Action, ): Promise<((req: any, action: Action) => Promise)[]> => { @@ -102,6 +135,7 @@ export const getChain = async ( ); pluginsInserted = true; } + if (!pluginsInserted) { console.log( `Inserting loaded plugins (${chainPluginLoader.pushPlugins.length} push, ${chainPluginLoader.pullPlugins.length} pull) into proxy chains`, @@ -109,7 +143,8 @@ export const getChain = async ( 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(1, 0, pluginObj.exec); + tagPushChain.splice(1, 0, pluginObj.exec); } for (const pluginObj of chainPluginLoader.pullPlugins) { console.log(`Inserting pull plugin ${pluginObj.constructor.name} into chain`); @@ -119,12 +154,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 getPushChain(action); + default: + return defaultActionChain; } }; @@ -138,8 +175,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/post-processor/audit.ts b/src/proxy/processors/post-processor/audit.ts index fd908fa39..e864ffe3f 100644 --- a/src/proxy/processors/post-processor/audit.ts +++ b/src/proxy/processors/post-processor/audit.ts @@ -15,10 +15,10 @@ */ import { writeAudit } from '../../../db'; -import { Action } from '../../actions'; +import { Action, RequestType } from '../../actions'; const exec = async (req: any, action: Action) => { - if (action.type !== 'pull') { + if (action.type !== RequestType.PULL) { await writeAudit(action); } From 5370825ea2a1f68fbe4ac5846b901be4306a1de8 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 11 Mar 2026 10:14:32 +0100 Subject: [PATCH 05/27] feat: add TagData type and DB support --- src/db/mongo/pushes.ts | 3 + src/db/types.ts | 25 +++++++ src/types/models.ts | 155 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 183 insertions(+) create mode 100644 src/types/models.ts diff --git a/src/db/mongo/pushes.ts b/src/db/mongo/pushes.ts index 688b92026..645a1cd0b 100644 --- a/src/db/mongo/pushes.ts +++ b/src/db/mongo/pushes.ts @@ -51,9 +51,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 bc809da9e..5a7ab335a 100644 --- a/src/db/types.ts +++ b/src/db/types.ts @@ -97,6 +97,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/types/models.ts b/src/types/models.ts new file mode 100644 index 000000000..dc4670dfe --- /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 + tag?: 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; +} From c84142c14c0f6046aa1d8e201c2b547b49d12d0e Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 11 Mar 2026 10:14:43 +0100 Subject: [PATCH 06/27] feat: add tag push UI support --- src/ui/utils.tsx | 16 +- src/ui/utils/pushUtils.ts | 260 ++++++++++++++++++ src/ui/views/PushDetails/PushDetails.tsx | 174 +++++++----- .../PushRequests/components/PushesTable.tsx | 221 ++++++++------- 4 files changed, 502 insertions(+), 169 deletions(-) create mode 100644 src/ui/utils/pushUtils.ts diff --git a/src/ui/utils.tsx b/src/ui/utils.tsx index 90b9abf08..3aa71e03a 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'; @@ -119,20 +119,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..7fc0afce8 --- /dev/null +++ b/src/ui/utils/pushUtils.ts @@ -0,0 +1,260 @@ +/** + * 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?.tag && pushData?.tagData && pushData.tagData.length > 0); +}; + +/** + * Gets the display timestamp for a push (handles both commits and tags) + * @param {boolean} isTag - Whether this is a tag push + * @param {CommitData | null} commitData - The commit data + * @param {TagData} [tagData] - The tag data (optional) + * @return {string} Formatted timestamp string or 'N/A' + */ +export const getDisplayTimestamp = ( + isTag: boolean, + commitData: CommitData | null, + tagData?: TagData, +): string => { + // For tag pushes, try to use tag timestamp if available + if (isTag && tagData?.timestamp) { + return moment.unix(parseInt(tagData.timestamp)).toString(); + } + + // Fallback to commit timestamp for both commits and tags without timestamp + if (commitData) { + const timestamp = commitData.commitTimestamp || commitData.commitTs; + return timestamp ? moment.unix(timestamp).toString() : 'N/A'; + } + + return 'N/A'; +}; + +/** + * Safely extracts tag name from git reference + * @param {string} [tagRef] - The git tag reference (e.g., 'refs/tags/v1.0.0') + * @return {string} The tag name without the 'refs/tags/' prefix + */ +export const getTagName = (tagRef?: string): string => { + if (!tagRef || typeof tagRef !== 'string') return ''; + try { + return tagRef.replace('refs/tags/', ''); + } catch (error) { + console.warn('Error parsing tag reference:', tagRef, error); + return ''; + } +}; + +/** + * 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.tag); + } + 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 getTagName(pushData.tag); + } + + 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 6f1441687..1ed0eb616 100644 --- a/src/ui/views/PushDetails/PushDetails.tsx +++ b/src/ui/views/PushDetails/PushDetails.tsx @@ -24,7 +24,6 @@ import Card from '../../components/Card/Card'; import CardIcon from '../../components/Card/CardIcon'; import CardBody from '../../components/Card/CardBody'; import CardHeader, { CardHeaderColor } from '../../components/Card/CardHeader'; -import CardFooter from '../../components/Card/CardFooter'; import Button from '../../components/CustomButtons/Button'; import Diff from './components/Diff'; import Attestation from './components/Attestation'; @@ -41,7 +40,14 @@ import type { ServiceResult } from '../../services/errors'; import { CheckCircle, Visibility, Cancel, Block } from '@material-ui/icons'; import Snackbar from '@material-ui/core/Snackbar'; import { PushActionView } from '../../types'; -import { trimPrefixRefsHeads, trimTrailingDotGit } from '../../../db/helper'; +import { + isTagPush, + getTagName, + getRepoFullName, + getRefToShow, + getGitUrl, +} from '../../utils/pushUtils'; +import { trimTrailingDotGit } from '../../../db/helper'; import { generateEmailLink, getGitProvider } from '../../utils'; const Dashboard: React.FC = () => { @@ -105,7 +111,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); @@ -141,12 +147,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) { @@ -207,87 +215,121 @@ const Dashboard: React.FC = () => {

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

-

Remote Head

+

Repository

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

-

Commit SHA

-

- - {push.commitTo} - -

+ {isTag ? ( + <> +

Tag

+

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

+ + ) : ( + <> +

Branch

+

{refToShow}

+ + )}
-

Repository

+

From

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

-

Branch

+

To

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

- - -

{headerData.title}

-
- - - - - Timestamp - Committer - Author - Message - - - - {push.commitData?.map((c) => ( - - - {moment.unix(Number(c.commitTimestamp || 0)).toString()} - - {generateEmailLink(c.committer, c.committerEmail)} - {generateEmailLink(c.author, c.authorEmail)} - {c.message} - - ))} - -
-
-
- - - - - - - - - + + {/* Branch push: show commits and diff */} + {!isTag && ( + <> + + + +

{headerData.title}

+
+ + + + + Timestamp + Committer + Author + Message + + + + {push.commitData?.map((c) => ( + + + {moment.unix(Number(c.commitTimestamp || 0)).toString()} + + {generateEmailLink(c.committer, c.committerEmail)} + {generateEmailLink(c.author, c.authorEmail)} + {c.message} + + ))} + +
+
+
+
+ + + + + + + + + )} + + {/* Tag push: show tagData */} + {isTag && ( + + + +

Tag Details

+
+ + + + + Tag Name + Tagger + Message + + + + {(push as any).tagData?.map((t: any) => ( + + {t.tagName} + {generateEmailLink(t.tagger, t.taggerEmail)} + {t.message} + + ))} + +
+
+
+
+ )} ); diff --git a/src/ui/views/PushRequests/components/PushesTable.tsx b/src/ui/views/PushRequests/components/PushesTable.tsx index b32e263e6..b88335bfe 100644 --- a/src/ui/views/PushRequests/components/PushesTable.tsx +++ b/src/ui/views/PushRequests/components/PushesTable.tsx @@ -16,7 +16,6 @@ import React, { useState, useEffect } from 'react'; import { makeStyles } from '@material-ui/core/styles'; -import moment from 'moment'; import { useNavigate } from 'react-router-dom'; import Button from '@material-ui/core/Button'; import Table from '@material-ui/core/Table'; @@ -31,9 +30,25 @@ 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, + 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 { [key: string]: any; @@ -84,15 +99,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.tag).toLowerCase(); + + return ( + repoName.includes(lowerCaseTerm) || + commitToSha.includes(lowerCaseTerm) || + message.includes(lowerCaseTerm) || + tagName.includes(lowerCaseTerm) + ); + }) : pushes; setFilteredData(filtered); setCurrentPage(1); @@ -111,93 +135,98 @@ 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.commitData?.[0], r.tagData?.[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} + + + + + ); + })} + +
+
+ +
+
); }; From ddfae875c1a02cdd8d877d1c1e2028e7e9e3ef57 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 11 Mar 2026 10:14:55 +0100 Subject: [PATCH 07/27] test: add unit and integration tests for tag push --- test/chain.test.ts | 60 +++++- test/db/mongo/push.test.ts | 3 + test/pushUtils.test.ts | 367 ++++++++++++++++++++++++++++++++ test/tagPushIntegration.test.ts | 250 ++++++++++++++++++++++ test/testParsePush.test.ts | 176 ++++++++++++++- 5 files changed, 847 insertions(+), 9 deletions(-) create mode 100644 test/pushUtils.test.ts create mode 100644 test/tagPushIntegration.test.ts diff --git a/test/chain.test.ts b/test/chain.test.ts index 12c4551f8..c1ea1368e 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, ActionType, RequestType } from '../src/proxy/actions'; const mockLoader = { pushPlugins: [ @@ -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); @@ -444,4 +444,58 @@ describe('proxy chain', function () { expect(consoleErrorSpy).toHaveBeenCalledWith('Error during auto-rejection:', error.message); }); + + 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 = ActionType.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 = ActionType.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: ActionType.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: ActionType.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/pushUtils.test.ts b/test/pushUtils.test.ts new file mode 100644 index 000000000..1c8dc2192 --- /dev/null +++ b/test/pushUtils.test.ts @@ -0,0 +1,367 @@ +/** + * 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', + tag: '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 isTag is true and tagData exists', () => { + const result = getDisplayTimestamp(true, mockCommitData[0] as any, mockTagData[0] as any); + expect(result).toContain('2022'); + }); + + it('returns commit timestamp when isTag is false', () => { + const result = getDisplayTimestamp(false, mockCommitData[0] as any); + expect(result).toContain('2022'); + }); + + it('returns commit timestamp when isTag is true but no tagData', () => { + const result = getDisplayTimestamp(true, mockCommitData[0] as any, undefined); + expect(result).toContain('2022'); + }); + + it('returns N/A when no valid timestamps', () => { + const result = getDisplayTimestamp(false, null as any); + expect(result).toBe('N/A'); + }); + + it('prefers commitTimestamp over commitTs', () => { + const commitWithBothTimestamps = { + commitTs: 1640995100, + commitTimestamp: 1640995200, + }; + const result = getDisplayTimestamp(false, 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 non-string input', () => { + expect(getTagName(123 as any)).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'); + }); + }); + + 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..3365ba3c1 --- /dev/null +++ b/test/tagPushIntegration.test.ts @@ -0,0 +1,250 @@ +/** + * 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', + tag: '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( + true, + fullTagPush.commitData[0], + 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.tag.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', + tag: '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', + tag: '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', + tag: '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', + tag: '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 = { + tag: '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 = { + tag: '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 b7c3f0507..bdecc357e 100644 --- a/test/testParsePush.test.ts +++ b/test/testParsePush.test.ts @@ -26,6 +26,7 @@ import { getContents, getPackMeta, parsePacketLines, + parseTag, } from '../src/proxy/processors/push-action/parsePush'; import { EMPTY_COMMIT_HASH, FLUSH_PACKET, PACK_SIGNATURE } from '../src/proxy/processors/constants'; @@ -128,8 +129,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, @@ -305,6 +310,29 @@ 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]); +} + describe('parsePackFile', () => { let action: any; let req: any; @@ -409,8 +437,8 @@ 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('push one ref at a time'); + expect(step.logs[0]).toContain('Invalid number of ref updates'); }); it('should add error step if multiple ref updates found', async () => { @@ -425,8 +453,8 @@ 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('push one ref at a time'); + expect(step.logs[0]).toContain('Invalid number of ref updates'); expect(step.logs[1]).toContain('Expected 1, but got 2'); }); @@ -933,6 +961,71 @@ describe('parsePackFile', () => { expect(action.setCommit).toHaveBeenCalledWith(EMPTY_COMMIT_HASH, newCommit); expect(action.commitData).toHaveLength(0); }); + + 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.tag).toBe(ref); + expect(action.branch).toBeUndefined(); + 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.tag).toBe(ref); + }); }); describe('getPackMeta', () => { @@ -1200,4 +1293,75 @@ describe('parsePackFile', () => { expect(() => parsePacketLines(incompleteBuffer)).toThrow(/Invalid packet line length 0008/); }); }); + + describe('parseTag', () => { + 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 = parseTag({ 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 = parseTag({ 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(() => parseTag({ 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(() => parseTag({ 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(() => parseTag({ type: 4, content } as any)).toThrow('Invalid tag object'); + }); + }); }); From 8675ef3902993f2d596f824de2e8540fb0690f4d Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 11 Mar 2026 10:15:05 +0100 Subject: [PATCH 08/27] test: add cypress e2e tests for tag push --- cypress/e2e/tagPush.cy.js | 143 ++++++++++++++++++++++++++++++++++++ cypress/support/commands.js | 63 ++++++++++++++++ 2 files changed, 206 insertions(+) create mode 100644 cypress/e2e/tagPush.cy.js diff --git a/cypress/e2e/tagPush.cy.js b/cypress/e2e/tagPush.cy.js new file mode 100644 index 000000000..b08cc1975 --- /dev/null +++ b/cypress/e2e/tagPush.cy.js @@ -0,0 +1,143 @@ +/** + * 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('has search functionality', () => { + cy.visit('/dashboard/push'); + cy.wait('@getPushes'); + + // Check search input exists + cy.get('input[type="text"]').first().should('exist'); + + // Test searching for tag name + cy.get('input[type="text"]').first().type('v1.0.0'); + cy.get('tbody tr').should('have.length.at.least', 1); + }); + + 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 1624d8ad6..29c010868 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -81,3 +81,66 @@ Cypress.Commands.add('getCSRFToken', () => { return cy.wrap(decodeURIComponent(token)); }); }); + +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', + tag: '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); +}); From 8dec82c99e04d9753d91efbe9c7765b9319109dc Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 11 Mar 2026 10:15:14 +0100 Subject: [PATCH 09/27] test: add cypress e2e tests for tag push --- cypress/e2e/tagPush.cy.js | 12 ------------ package-lock.json | 17 ----------------- 2 files changed, 29 deletions(-) diff --git a/cypress/e2e/tagPush.cy.js b/cypress/e2e/tagPush.cy.js index b08cc1975..a6fd9b54f 100644 --- a/cypress/e2e/tagPush.cy.js +++ b/cypress/e2e/tagPush.cy.js @@ -49,18 +49,6 @@ describe('Tag Push Functionality', () => { }); }); - it('has search functionality', () => { - cy.visit('/dashboard/push'); - cy.wait('@getPushes'); - - // Check search input exists - cy.get('input[type="text"]').first().should('exist'); - - // Test searching for tag name - cy.get('input[type="text"]').first().type('v1.0.0'); - cy.get('tbody tr').should('have.length.at.least', 1); - }); - it('can interact with push table entries', () => { cy.visit('/dashboard/push'); cy.wait('@getPushes'); diff --git a/package-lock.json b/package-lock.json index 7473fd2a1..8cb9a92e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1035,7 +1035,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -4347,7 +4346,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.8.tgz", "integrity": "sha512-ebO/Yl+EAvVe8DnMfi+iaAyIqYdK0q/q0y0rw82INWEKJOBe6b/P3YWE8NW7oOlF/nXFNrHwhARrN/hdgDkraA==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -4402,7 +4400,6 @@ "node_modules/@types/react": { "version": "17.0.74", "license": "MIT", - "peer": true, "dependencies": { "@types/prop-types": "*", "@types/scheduler": "*", @@ -4588,7 +4585,6 @@ "integrity": "sha512-IgSWvLobTDOjnaxAfDTIHaECbkNlAlKv2j5SjpB2v7QHKv1FIfjwMy8FsDbVfDX/KjmCmYICcw7uGaXLhtsLNg==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.56.0", "@typescript-eslint/types": "8.56.0", @@ -5166,7 +5162,6 @@ "version": "8.15.0", "dev": true, "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -5787,7 +5782,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001726", "electron-to-chromium": "^1.5.173", @@ -6979,7 +6973,6 @@ "version": "2.4.1", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "ansi-colors": "^4.1.1", "strip-ansi": "^6.0.1" @@ -7267,7 +7260,6 @@ "integrity": "sha512-LEyamqS7W5HB3ujJyvi0HQK/dtVINZvd5mAAp9eT5S/ujByGjiZLCzPcHVzuXbpJDJF/cxwHlfceVUDZ2lnSTw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -7700,7 +7692,6 @@ "resolved": "https://registry.npmjs.org/express-session/-/express-session-1.19.0.tgz", "integrity": "sha512-0csaMkGq+vaiZTmSMMGkfdCOabYv192VbytFypcvI0MANrp+4i/7yEkJ0sbAEhycQjntaKGzYfjfXQyVb7BHMA==", "license": "MIT", - "peer": true, "dependencies": { "cookie": "~0.7.2", "cookie-signature": "~1.0.7", @@ -10782,7 +10773,6 @@ "node_modules/mongodb": { "version": "5.9.2", "license": "Apache-2.0", - "peer": true, "dependencies": { "bson": "^5.5.0", "mongodb-connection-string-url": "^2.6.0", @@ -12080,7 +12070,6 @@ "node_modules/react": { "version": "16.14.0", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -12093,7 +12082,6 @@ "node_modules/react-dom": { "version": "16.14.0", "license": "MIT", - "peer": true, "dependencies": { "loose-envify": "^1.1.0", "object-assign": "^4.1.1", @@ -13475,7 +13463,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -13858,7 +13845,6 @@ "version": "5.9.3", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -14125,7 +14111,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -14260,7 +14245,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -14274,7 +14258,6 @@ "integrity": "sha512-LUCP5ev3GURDysTWiP47wRRUpLKMOfPh+yKTx3kVIEiu5KOMeqzpnYNsKyOoVrULivR8tLcks4+lga33Whn90A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@types/chai": "^5.2.2", "@vitest/expect": "3.2.4", From 7b37e81846bfa72046e626f0f1a9aa36caf99ed9 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 10 Jun 2026 11:32:28 +0200 Subject: [PATCH 10/27] feat: add DEFAULT value to RequestType enum --- src/proxy/actions/Action.ts | 2 ++ src/proxy/processors/pre-processor/parseAction.ts | 4 ++-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/src/proxy/actions/Action.ts b/src/proxy/actions/Action.ts index 06eb30eac..d843e5621 100644 --- a/src/proxy/actions/Action.ts +++ b/src/proxy/actions/Action.ts @@ -23,6 +23,8 @@ export enum RequestType { PUSH = 'push', PULL = 'pull', + + DEFAULT = 'default', } export enum ActionType { diff --git a/src/proxy/processors/pre-processor/parseAction.ts b/src/proxy/processors/pre-processor/parseAction.ts index b3a5e8b83..1a1586a5d 100644 --- a/src/proxy/processors/pre-processor/parseAction.ts +++ b/src/proxy/processors/pre-processor/parseAction.ts @@ -23,7 +23,7 @@ import * as db from '../../../db'; const exec = async (req: Request) => { const id = Date.now(); const timestamp = id; - let type: RequestType | string = '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 @@ -52,7 +52,7 @@ const exec = async (req: Request) => { ); } - return new Action(id.toString(), type as RequestType, req.method, timestamp, url); + return new Action(id.toString(), type, req.method, timestamp, url); }; exec.displayName = 'parseAction.exec'; From 87b12a9595eddb6900e2eb81dc399b3435f4c650 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 10 Jun 2026 11:50:36 +0200 Subject: [PATCH 11/27] refactor: merge ActionType.COMMIT into BRANCH --- src/proxy/actions/Action.ts | 4 ++-- src/proxy/chain.ts | 1 - src/proxy/processors/push-action/parsePush.ts | 9 +-------- 3 files changed, 3 insertions(+), 11 deletions(-) diff --git a/src/proxy/actions/Action.ts b/src/proxy/actions/Action.ts index d843e5621..dd6e15753 100644 --- a/src/proxy/actions/Action.ts +++ b/src/proxy/actions/Action.ts @@ -28,10 +28,10 @@ export enum RequestType { } export enum ActionType { - COMMIT = 'commit', - + /** Push to a tag ref (refs/tags/*) */ TAG = 'tag', + /** Push to a branch ref (refs/heads/*) or any other non-tag ref */ BRANCH = 'branch', } diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index 05074e4c4..c0c819183 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -123,7 +123,6 @@ const getPushChain = (action: Action): ((req: Request, action: Action) => Promis case ActionType.TAG: return tagPushChain; case ActionType.BRANCH: - case ActionType.COMMIT: default: return branchPushChain; } diff --git a/src/proxy/processors/push-action/parsePush.ts b/src/proxy/processors/push-action/parsePush.ts index c83c35574..5d8f8359f 100644 --- a/src/proxy/processors/push-action/parsePush.ts +++ b/src/proxy/processors/push-action/parsePush.ts @@ -94,14 +94,7 @@ async function exec(req: Request, action: Action): Promise { action.branch = isBranch ? refName : undefined; action.tag = isTag ? refName : undefined; - // Set actionType based on what type of push this is - if (isTag) { - action.actionType = ActionType.TAG; - } else if (isBranch) { - action.actionType = ActionType.BRANCH; - } else { - action.actionType = ActionType.COMMIT; - } + action.actionType = isTag ? ActionType.TAG : ActionType.BRANCH; // Note this will change the action.id to be based on the commits action.setCommit(oldCommit, newCommit); From ed018ba940d8acc9553104aa9d3382fe7d4204f2 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 10 Jun 2026 11:53:33 +0200 Subject: [PATCH 12/27] refactor: add REFS_PREFIX const --- src/proxy/processors/constants.ts | 1 + src/proxy/processors/push-action/parsePush.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/proxy/processors/constants.ts b/src/proxy/processors/constants.ts index 9de77e1e0..f5b7f0c90 100644 --- a/src/proxy/processors/constants.ts +++ b/src/proxy/processors/constants.ts @@ -16,6 +16,7 @@ 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'; diff --git a/src/proxy/processors/push-action/parsePush.ts b/src/proxy/processors/push-action/parsePush.ts index 5d8f8359f..4bc75ddc2 100644 --- a/src/proxy/processors/push-action/parsePush.ts +++ b/src/proxy/processors/push-action/parsePush.ts @@ -23,6 +23,7 @@ import { Action, Step, ActionType } from '../../actions'; import { CommitContent, CommitData, CommitHeader, PackMeta, PersonLine } from '../types'; import { TagData } from '../../../types/models'; import { + REFS_PREFIX, BRANCH_PREFIX, TAG_PREFIX, EMPTY_COMMIT_HASH, @@ -65,7 +66,7 @@ async function exec(req: Request, action: Action): Promise { } const [packetLines, packDataOffset] = parsePacketLines(req.body); - const refUpdates = packetLines.filter((line) => line.includes('refs/')); + const refUpdates = packetLines.filter((line) => line.includes(REFS_PREFIX)); if (refUpdates.length !== 1) { step.log('Invalid number of ref updates.'); From d36feb11b34016dd64741a297ec94c50cd02ede3 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 10 Jun 2026 12:54:05 +0200 Subject: [PATCH 13/27] feat: support multi-tag push --- src/proxy/actions/Action.ts | 2 +- src/proxy/processors/push-action/parsePush.ts | 58 ++++++------ src/types/models.ts | 2 +- src/ui/utils/pushUtils.ts | 35 +++++--- src/ui/views/PushDetails/PushDetails.tsx | 32 +++++-- .../PushRequests/components/PushesTable.tsx | 10 ++- test/pushUtils.test.ts | 20 +++-- test/tagPushIntegration.test.ts | 16 ++-- test/testParsePush.test.ts | 89 ++++++++++++++++--- 9 files changed, 184 insertions(+), 80 deletions(-) diff --git a/src/proxy/actions/Action.ts b/src/proxy/actions/Action.ts index dd6e15753..97bacd758 100644 --- a/src/proxy/actions/Action.ts +++ b/src/proxy/actions/Action.ts @@ -71,7 +71,7 @@ class Action { rejection?: Rejection; lastStep?: Step; proxyGitPath?: string; - tag?: string; + tags?: string[]; tagData?: TagData[]; newIdxFiles?: string[]; diff --git a/src/proxy/processors/push-action/parsePush.ts b/src/proxy/processors/push-action/parsePush.ts index 4bc75ddc2..b21da5e52 100644 --- a/src/proxy/processors/push-action/parsePush.ts +++ b/src/proxy/processors/push-action/parsePush.ts @@ -24,7 +24,6 @@ import { CommitContent, CommitData, CommitHeader, PackMeta, PersonLine } from '. import { TagData } from '../../../types/models'; import { REFS_PREFIX, - BRANCH_PREFIX, TAG_PREFIX, EMPTY_COMMIT_HASH, PACK_SIGNATURE, @@ -68,37 +67,44 @@ async function exec(req: Request, action: Action): Promise { const [packetLines, packDataOffset] = parsePacketLines(req.body); const refUpdates = packetLines.filter((line) => line.includes(REFS_PREFIX)); - if (refUpdates.length !== 1) { - step.log('Invalid number of ref updates.'); - step.log(`Expected 1, but got ${refUpdates.length}`); - throw new Error( - 'Your push has been blocked. Multi-ref pushes (multiple tags and/or branches) are not supported yet. Please push one ref 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 (refUpdates.length === 0) { + throw new Error('Your push has been blocked. No ref updates found.'); } - const [oldCommit, newCommit, ref] = parts; + 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), + }; + }); - // Strip everything after NUL, which is cap-list from - // https://git-scm.com/docs/http-protocol#_smart_server_response - const refName = ref.replace(/\0.*/, '').trim(); - const isTag = refName.startsWith(TAG_PREFIX); - const isBranch = refName.startsWith(BRANCH_PREFIX); + const allTags = parsedRefs.every((r) => r.isTag); - action.branch = isBranch ? refName : undefined; - action.tag = isTag ? refName : undefined; + 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. Multi-ref pushes are only supported for tags. Please push one branch at a time.', + ); + } - action.actionType = isTag ? ActionType.TAG : ActionType.BRANCH; + if (allTags) { + action.actionType = ActionType.TAG; + action.tags = parsedRefs.map((r) => r.refName); + } else { + action.actionType = ActionType.BRANCH; + action.branch = parsedRefs[0].refName; + } - // Note this will change the action.id to be based on the commits - action.setCommit(oldCommit, newCommit); + // Use the first ref's commit range for the action id + action.setCommit(parsedRefs[0].oldCommit, parsedRefs[0].newCommit); // Check if the offset is valid and if there's data after it if (packDataOffset >= req.body.length) { diff --git a/src/types/models.ts b/src/types/models.ts index dc4670dfe..6861d857f 100644 --- a/src/types/models.ts +++ b/src/types/models.ts @@ -81,7 +81,7 @@ export interface PushData { autoApproved?: boolean; timestamp: string | Date; // Tag-specific fields - tag?: string; + tags?: string[]; tagData?: TagData[]; user?: string; // Used for tag pushes as the tagger } diff --git a/src/ui/utils/pushUtils.ts b/src/ui/utils/pushUtils.ts index 7fc0afce8..86bc6d0ef 100644 --- a/src/ui/utils/pushUtils.ts +++ b/src/ui/utils/pushUtils.ts @@ -24,7 +24,9 @@ import { trimPrefixRefsHeads, trimTrailingDotGit } from '../../db/helper'; * @return {boolean} True if this is a tag push, false otherwise */ export const isTagPush = (pushData: PushData): boolean => { - return Boolean(pushData?.tag && pushData?.tagData && pushData.tagData.length > 0); + return Boolean( + pushData?.tags && pushData.tags.length > 0 && pushData?.tagData && pushData.tagData.length > 0, + ); }; /** @@ -54,18 +56,23 @@ export const getDisplayTimestamp = ( }; /** - * Safely extracts tag name from git reference - * @param {string} [tagRef] - The git tag reference (e.g., 'refs/tags/v1.0.0') - * @return {string} The tag name without the 'refs/tags/' prefix + * 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 = (tagRef?: string): string => { - if (!tagRef || typeof tagRef !== 'string') return ''; - try { - return tagRef.replace('refs/tags/', ''); - } catch (error) { - console.warn('Error parsing tag reference:', tagRef, error); - return ''; - } +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/', ''); }; /** @@ -75,7 +82,7 @@ export const getTagName = (tagRef?: string): string => { */ export const getRefToShow = (pushData: PushData): string => { if (isTagPush(pushData)) { - return getTagName(pushData.tag); + return getTagName(pushData.tags); } return trimPrefixRefsHeads(pushData.branch); }; @@ -87,7 +94,7 @@ export const getRefToShow = (pushData: PushData): string => { */ export const getShaOrTag = (pushData: PushData): string => { if (isTagPush(pushData)) { - return getTagName(pushData.tag); + return getFirstTagName(pushData.tags); } if (!pushData.commitTo || typeof pushData.commitTo !== 'string') { diff --git a/src/ui/views/PushDetails/PushDetails.tsx b/src/ui/views/PushDetails/PushDetails.tsx index 452763b76..5fd2e6e62 100644 --- a/src/ui/views/PushDetails/PushDetails.tsx +++ b/src/ui/views/PushDetails/PushDetails.tsx @@ -232,8 +232,8 @@ const Dashboard: React.FC = () => { {isTag ? ( <> -

Tag

-

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

+

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

+

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

) : ( <> @@ -281,13 +281,27 @@ const Dashboard: React.FC = () => { - {(push as any).tagData?.map((t: any) => ( - - {t.tagName} - {generateEmailLink(t.tagger, t.taggerEmail)} - {t.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 || '-'} + + ); + })} ), diff --git a/src/ui/views/PushRequests/components/PushesTable.tsx b/src/ui/views/PushRequests/components/PushesTable.tsx index 0bfcf32e3..57c41da98 100644 --- a/src/ui/views/PushRequests/components/PushesTable.tsx +++ b/src/ui/views/PushRequests/components/PushesTable.tsx @@ -34,6 +34,7 @@ import { isTagPush, getDisplayTimestamp, getTagName, + getFirstTagName, getRefToShow, getShaOrTag, getCommitterOrTagger, @@ -108,7 +109,7 @@ const PushesTable: React.FC = (props) => { const repoName = getRepoFullName(row.repo).toLowerCase(); const message = getMessage(row).toLowerCase(); const commitToSha = (row.commitTo ?? '').toLowerCase(); - const tagName = getTagName(row.tag).toLowerCase(); + const tagName = getTagName(row.tags).toLowerCase(); return ( repoName.includes(lowerCaseTerm) || @@ -179,7 +180,12 @@ const PushesTable: React.FC = (props) => { diff --git a/test/pushUtils.test.ts b/test/pushUtils.test.ts index 1c8dc2192..de9277a49 100644 --- a/test/pushUtils.test.ts +++ b/test/pushUtils.test.ts @@ -65,7 +65,7 @@ describe('pushUtils', () => { id: 'push-2', repo: 'test-repo.git', branch: 'refs/heads/main', - tag: 'refs/tags/v1.0.0', + tags: ['refs/tags/v1.0.0'], tagData: mockTagData, user: 'release-bot', commitTo: '1234567890abcdef', @@ -124,11 +124,11 @@ describe('pushUtils', () => { describe('getTagName', () => { it('extracts tag name from refs/tags/ reference', () => { - expect(getTagName('refs/tags/v1.0.0')).toBe('v1.0.0'); + 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'); + expect(getTagName(['v1.0.0'])).toBe('v1.0.0'); }); it('returns empty string for undefined input', () => { @@ -139,12 +139,16 @@ describe('pushUtils', () => { expect(getTagName(null as any)).toBe(''); }); - it('returns empty string for non-string input', () => { - expect(getTagName(123 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'); + 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'); }); }); @@ -339,8 +343,8 @@ describe('pushUtils', () => { 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'); + expect(() => getTagName(['malformed-ref'])).not.toThrow(); + expect(getTagName(['malformed-ref'])).toBe('malformed-ref'); }); it('handles missing properties gracefully', () => { diff --git a/test/tagPushIntegration.test.ts b/test/tagPushIntegration.test.ts index 3365ba3c1..aca22cf7e 100644 --- a/test/tagPushIntegration.test.ts +++ b/test/tagPushIntegration.test.ts @@ -32,7 +32,7 @@ describe('Tag Push Integration', () => { id: 'tag-push-123', repo: 'finos/git-proxy.git', branch: 'refs/heads/main', - tag: 'refs/tags/v2.1.0', + tags: ['refs/tags/v2.1.0'], user: 'release-manager', commitFrom: '0000000000000000000000000000000000000000', commitTo: 'abcdef1234567890abcdef1234567890abcdef12', @@ -103,7 +103,7 @@ describe('Tag Push Integration', () => { const searchableFields = { repoName: getRepoFullName(fullTagPush.repo).toLowerCase(), message: getMessage(fullTagPush).toLowerCase(), - tagName: fullTagPush.tag.replace('refs/tags/', '').toLowerCase(), + tagName: fullTagPush.tags[0].replace('refs/tags/', '').toLowerCase(), }; expect(searchableFields.repoName).toContain('finos'); expect(searchableFields.message).toContain('release'); @@ -115,7 +115,7 @@ describe('Tag Push Integration', () => { const lightweightTagPush = { id: 'lightweight-tag-123', repo: 'example/repo.git', - tag: 'refs/tags/quick-fix', + tags: ['refs/tags/quick-fix'], user: 'hotfix-user', commitTo: 'fedcba0987654321fedcba0987654321fedcba09', tagData: [{ tagName: 'quick-fix', type: 'lightweight', tagger: 'hotfix-user', message: '' }], @@ -146,7 +146,7 @@ describe('Tag Push Integration', () => { const incompleteTagPush = { id: 'incomplete-tag', repo: 'test/repo.git', - tag: 'refs/tags/broken-tag', + tags: ['refs/tags/broken-tag'], user: 'test-user', commitData: [], tagData: [], @@ -159,7 +159,7 @@ describe('Tag Push Integration', () => { const malformedTagPush = { id: 'malformed-tag', repo: 'test/repo.git', - tag: 'malformed-tag-ref', + tags: ['malformed-tag-ref'], tagData: [ { tagName: 'v1.0.0', type: 'annotated', tagger: 'test-user', message: 'Test release' }, ], @@ -176,7 +176,7 @@ describe('Tag Push Integration', () => { const complexTagPush = { id: 'complex-tag', repo: 'test/repo.git', - tag: 'refs/tags/v1.0.0-beta.1+build.123', + tags: ['refs/tags/v1.0.0-beta.1+build.123'], tagData: [ { tagName: 'v1.0.0-beta.1+build.123', @@ -219,7 +219,7 @@ describe('Tag Push Integration', () => { it('differentiates between tag and commit pushes', () => { const tagPush = { - tag: 'refs/tags/v1.0.0', + tags: ['refs/tags/v1.0.0'], tagData: [{ tagName: 'v1.0.0' }], commitData: [], } as any; @@ -238,7 +238,7 @@ describe('Tag Push Integration', () => { it('shows different committer/author behavior', () => { const tagPushWithUser = { - tag: 'refs/tags/v1.0.0', + tags: ['refs/tags/v1.0.0'], tagData: [{ tagName: 'v1.0.0' }], user: 'tag-creator', commitData: [{ committer: 'original-committer' }], diff --git a/test/testParsePush.test.ts b/test/testParsePush.test.ts index 500a63830..2e68014fe 100644 --- a/test/testParsePush.test.ts +++ b/test/testParsePush.test.ts @@ -350,6 +350,24 @@ function createSampleTagPackBuffer( 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; @@ -454,11 +472,10 @@ describe('parsePackFile', () => { const step = action.steps[0]; expect(step.stepName).toBe('parsePackFile'); expect(step.error).toBe(true); - expect(step.errorMessage).toContain('push one ref at a time'); - expect(step.logs[0]).toContain('Invalid number of ref 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', @@ -470,9 +487,7 @@ describe('parsePackFile', () => { const step = action.steps[0]; expect(step.stepName).toBe('parsePackFile'); expect(step.error).toBe(true); - expect(step.errorMessage).toContain('push one ref at a time'); - expect(step.logs[0]).toContain('Invalid number of ref 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 () => { @@ -485,8 +500,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 () => { @@ -1003,8 +1016,8 @@ describe('parsePackFile', () => { expect(step.error).toBe(false); expect(step.errorMessage).toBeNull(); - expect(action.tag).toBe(ref); - expect(action.branch).toBeUndefined(); + 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); @@ -1041,7 +1054,61 @@ describe('parsePackFile', () => { expect(result).toBe(action); expect(action.actionType).toBe('tag'); - expect(action.tag).toBe(ref); + 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'); }); }); From f3845ade128982aeabef06333943f779c43b57b7 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 10 Jun 2026 12:54:48 +0200 Subject: [PATCH 14/27] refactor: removing intermediate ParsedObjects in parsePush --- src/proxy/processors/push-action/parsePush.ts | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/src/proxy/processors/push-action/parsePush.ts b/src/proxy/processors/push-action/parsePush.ts index b21da5e52..a4964b99c 100644 --- a/src/proxy/processors/push-action/parsePush.ts +++ b/src/proxy/processors/push-action/parsePush.ts @@ -122,19 +122,14 @@ async function exec(req: Request, action: Action): Promise { const [meta, contentBuff] = getPackMeta(buf); const contents = await getContents(contentBuff, meta.entries); - const ParsedObjects = { - commits: [] as CommitData[], - tags: [] as TagData[], - }; + action.commitData = []; + action.tagData = []; for (const obj of contents) { - if (obj.type === GIT_OBJECT_TYPE_COMMIT) ParsedObjects.commits.push(...getCommitData([obj])); - else if (obj.type === GIT_OBJECT_TYPE_TAG) ParsedObjects.tags.push(parseTag(obj)); + if (obj.type === GIT_OBJECT_TYPE_COMMIT) action.commitData.push(...getCommitData([obj])); + else if (obj.type === GIT_OBJECT_TYPE_TAG) action.tagData.push(parseTag(obj)); } - action.commitData = ParsedObjects.commits; - action.tagData = ParsedObjects.tags; - if (action.commitData.length) { if (action.commitFrom === EMPTY_COMMIT_HASH) { action.commitFrom = action.commitData[action.commitData.length - 1].parent; From fb92bb9879409bd41a56c41a6d1c5a6f058ab0df Mon Sep 17 00:00:00 2001 From: Fabio Vincenzi <93596376+fabiovincenzi@users.noreply.github.com> Date: Thu, 11 Jun 2026 16:25:49 +0200 Subject: [PATCH 15/27] fix: update log message to mention both commit and tag data Co-authored-by: Kris West Signed-off-by: Fabio Vincenzi <93596376+fabiovincenzi@users.noreply.github.com> --- src/proxy/processors/push-action/parsePush.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/proxy/processors/push-action/parsePush.ts b/src/proxy/processors/push-action/parsePush.ts index a4964b99c..5c23c0233 100644 --- a/src/proxy/processors/push-action/parsePush.ts +++ b/src/proxy/processors/push-action/parsePush.ts @@ -144,7 +144,7 @@ async function exec(req: Request, action: Action): Promise { action.user = action.tagData.at(-1)!.tagger; action.userEmail = action.tagData.at(-1)!.taggerEmail; } else { - step.log('No commit data found when parsing push.'); + step.log('No commit or tag data found when parsing push.'); } step.content = { From 91ed17fb8bd08bb7e76ce88cb8156a7ad011dfea Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Thu, 11 Jun 2026 16:26:59 +0200 Subject: [PATCH 16/27] refactor: use action.actionType instead of array length checks in parsePush --- src/proxy/processors/push-action/parsePush.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/proxy/processors/push-action/parsePush.ts b/src/proxy/processors/push-action/parsePush.ts index a4964b99c..3d1f848d0 100644 --- a/src/proxy/processors/push-action/parsePush.ts +++ b/src/proxy/processors/push-action/parsePush.ts @@ -130,7 +130,10 @@ async function exec(req: Request, action: Action): Promise { else if (obj.type === GIT_OBJECT_TYPE_TAG) action.tagData.push(parseTag(obj)); } - if (action.commitData.length) { + if (action.actionType === ActionType.TAG && action.tagData.length) { + action.user = action.tagData.at(-1)!.tagger; + action.userEmail = action.tagData.at(-1)!.taggerEmail; + } else if (action.commitData.length) { if (action.commitFrom === EMPTY_COMMIT_HASH) { action.commitFrom = action.commitData[action.commitData.length - 1].parent; } @@ -140,9 +143,6 @@ async function exec(req: Request, action: Action): Promise { step.log(`Push request received from user ${committer} with email ${committerEmail}`); action.user = committer; action.userEmail = committerEmail; - } else if (action.tagData?.length) { - action.user = action.tagData.at(-1)!.tagger; - action.userEmail = action.tagData.at(-1)!.taggerEmail; } else { step.log('No commit data found when parsing push.'); } From 33b1d501c416e559675182430075ff472f80fc20 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Thu, 11 Jun 2026 17:00:18 +0200 Subject: [PATCH 17/27] refactor(parsePush): structure user extraction by actionType --- src/proxy/processors/push-action/parsePush.ts | 34 ++++++++++-------- test/testParsePush.test.ts | 36 +++++++++++++++++++ 2 files changed, 56 insertions(+), 14 deletions(-) diff --git a/src/proxy/processors/push-action/parsePush.ts b/src/proxy/processors/push-action/parsePush.ts index 88d378740..8bf82d739 100644 --- a/src/proxy/processors/push-action/parsePush.ts +++ b/src/proxy/processors/push-action/parsePush.ts @@ -130,21 +130,27 @@ async function exec(req: Request, action: Action): Promise { else if (obj.type === GIT_OBJECT_TYPE_TAG) action.tagData.push(parseTag(obj)); } - if (action.actionType === ActionType.TAG && action.tagData.length) { - action.user = action.tagData.at(-1)!.tagger; - action.userEmail = action.tagData.at(-1)!.taggerEmail; - } else if (action.commitData.length) { - if (action.commitFrom === EMPTY_COMMIT_HASH) { - action.commitFrom = action.commitData[action.commitData.length - 1].parent; + if (action.actionType === ActionType.TAG) { + if (action.tagData.length) { + action.user = action.tagData.at(-1)!.tagger; + action.userEmail = action.tagData.at(-1)!.taggerEmail; + } else { + step.log('No tag data found when parsing push.'); + } + } else if (action.actionType === ActionType.BRANCH) { + if (action.commitData.length) { + if (action.commitFrom === EMPTY_COMMIT_HASH) { + action.commitFrom = action.commitData[action.commitData.length - 1].parent; + } + 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.'); } - 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 or tag data found when parsing push.'); } step.content = { diff --git a/test/testParsePush.test.ts b/test/testParsePush.test.ts index 2e68014fe..f9d7573ae 100644 --- a/test/testParsePush.test.ts +++ b/test/testParsePush.test.ts @@ -375,11 +375,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); @@ -549,11 +553,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); @@ -602,6 +608,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); @@ -645,11 +652,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); @@ -987,9 +996,12 @@ 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 () => { @@ -1110,6 +1122,30 @@ describe('parsePackFile', () => { expect(step.error).toBe(true); expect(step.errorMessage).toContain('push one branch at a time'); }); + + it('should handle tag push with empty tagData (lightweight tag)', 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(false); + + expect(action.actionType).toBe('tag'); + expect(action.tags).toEqual([ref]); + expect(action.branch).toBeNull(); + expect(action.tagData).toHaveLength(0); + expect(action.user).toBeNull(); + expect(action.userEmail).toBeNull(); + }); }); describe('getPackMeta', () => { From 2d294a0e380ae831768fe4caedb6aa36446ddaa0 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Thu, 11 Jun 2026 17:07:50 +0200 Subject: [PATCH 18/27] refactor(parsePush): rename parseTag to getTagData --- src/proxy/processors/push-action/parsePush.ts | 6 +++--- test/testParsePush.test.ts | 14 +++++++------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/src/proxy/processors/push-action/parsePush.ts b/src/proxy/processors/push-action/parsePush.ts index 8bf82d739..f7422d558 100644 --- a/src/proxy/processors/push-action/parsePush.ts +++ b/src/proxy/processors/push-action/parsePush.ts @@ -127,7 +127,7 @@ async function exec(req: Request, action: Action): Promise { 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(parseTag(obj)); + else if (obj.type === GIT_OBJECT_TYPE_TAG) action.tagData.push(getTagData(obj)); } if (action.actionType === ActionType.TAG) { @@ -165,7 +165,7 @@ async function exec(req: Request, action: Action): Promise { return action; } -function parseTag(x: CommitContent): TagData { +function getTagData(x: CommitContent): TagData { const lines = x.content.split('\n'); const object = lines .find((l) => l.startsWith('object ')) @@ -664,4 +664,4 @@ const parsePacketLines = (buffer: Buffer): [string[], number] => { exec.displayName = 'parsePush.exec'; -export { exec, getCommitData, getContents, getPackMeta, parsePacketLines, parseTag }; +export { exec, getCommitData, getContents, getPackMeta, parsePacketLines, getTagData }; diff --git a/test/testParsePush.test.ts b/test/testParsePush.test.ts index f9d7573ae..36d9af8fd 100644 --- a/test/testParsePush.test.ts +++ b/test/testParsePush.test.ts @@ -26,7 +26,7 @@ import { getContents, getPackMeta, parsePacketLines, - parseTag, + getTagData, } from '../src/proxy/processors/push-action/parsePush'; import { EMPTY_COMMIT_HASH, FLUSH_PACKET, PACK_SIGNATURE } from '../src/proxy/processors/constants'; import { CommitContent } from '../src/proxy/processors/types'; @@ -1412,7 +1412,7 @@ describe('parsePackFile', () => { }); }); - describe('parseTag', () => { + describe('getTagData', () => { it('should parse a valid tag object', () => { const content = 'object 1234567890abcdef1234567890abcdef12345678\n' + @@ -1421,7 +1421,7 @@ describe('parsePackFile', () => { 'tagger Test Tagger 1234567890 +0000\n\n' + 'Release v1.0.0'; - const result = parseTag({ type: 4, content } as any); + const result = getTagData({ type: 4, content } as any); expect(result.object).toBe('1234567890abcdef1234567890abcdef12345678'); expect(result.type).toBe('commit'); @@ -1440,7 +1440,7 @@ describe('parsePackFile', () => { 'tagger Releaser 9876543210 +0100\n\n' + 'Release v2.0.0\n\nThis release includes:\n- Feature A\n- Bug fix B'; - const result = parseTag({ type: 4, content } as any); + const result = getTagData({ type: 4, content } as any); expect(result.tagName).toBe('v2.0.0'); expect(result.tagger).toBe('Releaser'); @@ -1457,7 +1457,7 @@ describe('parsePackFile', () => { 'tag v1.0.0\n\n' + 'Release without tagger'; - expect(() => parseTag({ type: 4, content } as any)).toThrow( + expect(() => getTagData({ type: 4, content } as any)).toThrow( 'Invalid tag object: no tagger line', ); }); @@ -1469,7 +1469,7 @@ describe('parsePackFile', () => { 'tagger Test Tagger 1234567890 +0000\n\n' + 'Message'; - expect(() => parseTag({ type: 4, content } as any)).toThrow('Invalid tag object'); + expect(() => getTagData({ type: 4, content } as any)).toThrow('Invalid tag object'); }); it('should throw if tag name is missing', () => { @@ -1479,7 +1479,7 @@ describe('parsePackFile', () => { 'tagger Test Tagger 1234567890 +0000\n\n' + 'Message'; - expect(() => parseTag({ type: 4, content } as any)).toThrow('Invalid tag object'); + expect(() => getTagData({ type: 4, content } as any)).toThrow('Invalid tag object'); }); }); }); From 167d9d060c971108cb589d7e569db534f1158db2 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Thu, 11 Jun 2026 17:24:55 +0200 Subject: [PATCH 19/27] feat(chain): validate tag messages with the same rules as commit messages --- src/proxy/chain.ts | 4 +- ...heckCommitMessages.ts => checkMessages.ts} | 19 +++--- src/proxy/processors/push-action/index.ts | 4 +- test/chain.test.ts | 8 +-- ...Messages.test.ts => checkMessages.test.ts} | 65 ++++++++++++++++--- 5 files changed, 72 insertions(+), 28 deletions(-) rename src/proxy/processors/push-action/{checkCommitMessages.ts => checkMessages.ts} (80%) rename test/processors/{checkCommitMessages.test.ts => checkMessages.test.ts} (87%) diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index c0c819183..9e69a39b6 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -25,7 +25,7 @@ import { handleErrorAndLog } from '../utils/errors'; const branchPushChain: ((req: Request, action: Action) => Promise)[] = [ 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 @@ -43,10 +43,10 @@ const tagPushChain: ((req: Request, action: Action) => Promise)[] = [ proc.push.checkRepoInAuthorisedList, proc.push.checkUserPushPermission, proc.push.checkIfWaitingAuth, + proc.push.checkMessages, proc.push.pullRemote, proc.push.writePack, proc.push.preReceive, - // TODO: implement tag message validation? proc.push.blockForAuth, ]; 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..6387aca20 100644 --- a/src/proxy/processors/push-action/index.ts +++ b/src/proxy/processors/push-action/index.ts @@ -26,7 +26,7 @@ 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'; @@ -44,7 +44,7 @@ export { scanDiff, blockForAuth, checkIfWaitingAuth, - checkCommitMessages, + checkMessages, checkAuthorEmails, checkUserPushPermission, checkEmptyBranch, diff --git a/test/chain.test.ts b/test/chain.test.ts index f243d077f..516470e1b 100644 --- a/test/chain.test.ts +++ b/test/chain.test.ts @@ -32,7 +32,7 @@ const initMockPushProcessors = () => { parsePush: vi.fn(), checkEmptyBranch: vi.fn(), checkRepoInAuthorisedList: vi.fn(), - checkCommitMessages: vi.fn(), + checkMessages: vi.fn(), checkAuthorEmails: vi.fn(), checkUserPushPermission: vi.fn(), checkIfWaitingAuth: vi.fn(), @@ -156,7 +156,7 @@ describe('proxy chain', function () { expect(mockPushProcessors.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(); @@ -201,7 +201,7 @@ describe('proxy chain', function () { expect(mockPushProcessors.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(); @@ -239,7 +239,7 @@ describe('proxy chain', function () { expect(mockPushProcessors.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(); 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); + }); + }); }); }); From 20e86c0b77fa127b6561ca5fe591a74e65f7cfa6 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Thu, 11 Jun 2026 17:36:15 +0200 Subject: [PATCH 20/27] refactor(chain): inline getPushChain --- src/proxy/chain.ts | 17 +---------------- 1 file changed, 1 insertion(+), 16 deletions(-) diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index 9e69a39b6..b347a1043 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -113,21 +113,6 @@ export const executeChain = async (req: Request, _res: Response): Promise Promise)[] => { - switch (action.actionType) { - case ActionType.TAG: - return tagPushChain; - case ActionType.BRANCH: - default: - return branchPushChain; - } -}; - export const getChain = async ( action: Action, ): Promise<((req: Request, action: Action) => Promise)[]> => { @@ -161,7 +146,7 @@ export const getChain = async ( case RequestType.PULL: return pullActionChain; case RequestType.PUSH: - return getPushChain(action); + return action.actionType === ActionType.TAG ? tagPushChain : branchPushChain; default: return defaultActionChain; } From 227aef1b5490a5b73decd0e7376f3744c92277d4 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Thu, 11 Jun 2026 17:39:45 +0200 Subject: [PATCH 21/27] refactor(pushUtils): use type guard for getDisplayTimestamp --- src/ui/utils/pushUtils.ts | 29 ++++++++----------- .../PushRequests/components/PushesTable.tsx | 2 +- test/pushUtils.test.ts | 20 ++++++------- test/tagPushIntegration.test.ts | 6 +--- 4 files changed, 24 insertions(+), 33 deletions(-) diff --git a/src/ui/utils/pushUtils.ts b/src/ui/utils/pushUtils.ts index 86bc6d0ef..919bedee2 100644 --- a/src/ui/utils/pushUtils.ts +++ b/src/ui/utils/pushUtils.ts @@ -29,30 +29,25 @@ export const isTagPush = (pushData: PushData): boolean => { ); }; +/** + * 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 {boolean} isTag - Whether this is a tag push - * @param {CommitData | null} commitData - The commit data - * @param {TagData} [tagData] - The tag data (optional) + * @param {CommitData | TagData | null | undefined} data - The commit or tag data * @return {string} Formatted timestamp string or 'N/A' */ -export const getDisplayTimestamp = ( - isTag: boolean, - commitData: CommitData | null, - tagData?: TagData, -): string => { - // For tag pushes, try to use tag timestamp if available - if (isTag && tagData?.timestamp) { - return moment.unix(parseInt(tagData.timestamp)).toString(); - } +export const getDisplayTimestamp = (data?: CommitData | TagData | null): string => { + if (!data) return 'N/A'; - // Fallback to commit timestamp for both commits and tags without timestamp - if (commitData) { - const timestamp = commitData.commitTimestamp || commitData.commitTs; - return timestamp ? moment.unix(timestamp).toString() : 'N/A'; + if (isTagData(data)) { + return data.timestamp ? moment.unix(parseInt(data.timestamp)).toString() : 'N/A'; } - return 'N/A'; + const timestamp = data.commitTimestamp || data.commitTs; + return timestamp ? moment.unix(timestamp).toString() : 'N/A'; }; /** diff --git a/src/ui/views/PushRequests/components/PushesTable.tsx b/src/ui/views/PushRequests/components/PushesTable.tsx index 57c41da98..c950d14b5 100644 --- a/src/ui/views/PushRequests/components/PushesTable.tsx +++ b/src/ui/views/PushRequests/components/PushesTable.tsx @@ -159,7 +159,7 @@ const PushesTable: React.FC = (props) => { const r = row as any; const isTag = isTagPush(r); const repoFullName = getRepoFullName(r.repo); - const displayTime = getDisplayTimestamp(isTag, r.commitData?.[0], r.tagData?.[0]); + const displayTime = getDisplayTimestamp(isTag ? r.tagData?.[0] : r.commitData?.[0]); const refToShow = getRefToShow(r); const shaOrTag = getShaOrTag(r); const repoUrl = r.url; diff --git a/test/pushUtils.test.ts b/test/pushUtils.test.ts index de9277a49..e1264666a 100644 --- a/test/pushUtils.test.ts +++ b/test/pushUtils.test.ts @@ -92,23 +92,23 @@ describe('pushUtils', () => { }); describe('getDisplayTimestamp', () => { - it('returns tag timestamp when isTag is true and tagData exists', () => { - const result = getDisplayTimestamp(true, mockCommitData[0] as any, mockTagData[0] as any); + it('returns tag timestamp when tagData is passed', () => { + const result = getDisplayTimestamp(mockTagData[0] as any); expect(result).toContain('2022'); }); - it('returns commit timestamp when isTag is false', () => { - const result = getDisplayTimestamp(false, mockCommitData[0] as any); + it('returns commit timestamp when commitData is passed', () => { + const result = getDisplayTimestamp(mockCommitData[0] as any); expect(result).toContain('2022'); }); - it('returns commit timestamp when isTag is true but no tagData', () => { - const result = getDisplayTimestamp(true, mockCommitData[0] as any, undefined); - expect(result).toContain('2022'); + it('returns N/A when null', () => { + const result = getDisplayTimestamp(null); + expect(result).toBe('N/A'); }); - it('returns N/A when no valid timestamps', () => { - const result = getDisplayTimestamp(false, null as any); + it('returns N/A when undefined', () => { + const result = getDisplayTimestamp(undefined); expect(result).toBe('N/A'); }); @@ -117,7 +117,7 @@ describe('pushUtils', () => { commitTs: 1640995100, commitTimestamp: 1640995200, }; - const result = getDisplayTimestamp(false, commitWithBothTimestamps as any); + const result = getDisplayTimestamp(commitWithBothTimestamps as any); expect(result).toContain('2022'); }); }); diff --git a/test/tagPushIntegration.test.ts b/test/tagPushIntegration.test.ts index aca22cf7e..8b3e74fe7 100644 --- a/test/tagPushIntegration.test.ts +++ b/test/tagPushIntegration.test.ts @@ -90,11 +90,7 @@ describe('Tag Push Integration', () => { }); it('uses tag timestamp over commit timestamp', () => { - const displayTime = getDisplayTimestamp( - true, - fullTagPush.commitData[0], - fullTagPush.tagData[0], - ); + const displayTime = getDisplayTimestamp(fullTagPush.tagData[0]); expect(displayTime).toContain('2024'); expect(displayTime).toContain('Jan 15'); }); From dc2a230174c92f7369882e5a8548c4f880869c73 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Mon, 15 Jun 2026 12:21:55 +0200 Subject: [PATCH 22/27] feat(parsePush): block lightweight tags with error --- src/proxy/processors/push-action/parsePush.ts | 5 ++++- test/testParsePush.test.ts | 12 +++--------- 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/proxy/processors/push-action/parsePush.ts b/src/proxy/processors/push-action/parsePush.ts index df9d9ad70..28cab6819 100644 --- a/src/proxy/processors/push-action/parsePush.ts +++ b/src/proxy/processors/push-action/parsePush.ts @@ -134,7 +134,10 @@ async function exec(req: Request, action: Action): Promise { action.user = action.tagData.at(-1)!.tagger; action.userEmail = action.tagData.at(-1)!.taggerEmail; } else { - step.log('No tag data found when parsing push.'); + // 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 === ActionType.BRANCH) { if (action.commitData.length && action.commitFrom === EMPTY_COMMIT_HASH) { diff --git a/test/testParsePush.test.ts b/test/testParsePush.test.ts index feb3ff840..405a33b63 100644 --- a/test/testParsePush.test.ts +++ b/test/testParsePush.test.ts @@ -1124,7 +1124,7 @@ describe('parsePackFile', () => { expect(step.errorMessage).toContain('push one branch at a time'); }); - it('should handle tag push with empty tagData (lightweight tag)', async () => { + 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'; @@ -1138,14 +1138,8 @@ describe('parsePackFile', () => { 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([ref]); - expect(action.branch).toBeNull(); - expect(action.tagData).toHaveLength(0); - expect(action.user).toBeNull(); - expect(action.userEmail).toBeNull(); + expect(step.error).toBe(true); + expect(step.errorMessage).toContain('Lightweight (non-annotated) tags are not supported'); }); }); From 276adc8ebc0569ceddd329e2d2efeb7abc8e0153 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Mon, 15 Jun 2026 12:28:38 +0200 Subject: [PATCH 23/27] refactor(actions): rename ActionType to PushType --- src/proxy/actions/Action.ts | 4 ++-- src/proxy/actions/index.ts | 4 ++-- src/proxy/chain.ts | 4 ++-- src/proxy/processors/push-action/parsePush.ts | 10 +++++----- test/chain.test.ts | 10 +++++----- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/src/proxy/actions/Action.ts b/src/proxy/actions/Action.ts index e886a3df8..d40507a38 100644 --- a/src/proxy/actions/Action.ts +++ b/src/proxy/actions/Action.ts @@ -27,7 +27,7 @@ export enum RequestType { DEFAULT = 'default', } -export enum ActionType { +export enum PushType { /** Push to a tag ref (refs/tags/*) */ TAG = 'tag', @@ -41,7 +41,7 @@ export enum ActionType { class Action { id: string; type: RequestType; - actionType?: ActionType; + actionType?: PushType; method: string; timestamp: number; project: string; diff --git a/src/proxy/actions/index.ts b/src/proxy/actions/index.ts index 5914fbbfb..5ca33f87a 100644 --- a/src/proxy/actions/index.ts +++ b/src/proxy/actions/index.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Action, RequestType, ActionType } from './Action'; +import { Action, RequestType, PushType } from './Action'; import { Step } from './Step'; -export { Action, Step, RequestType, ActionType }; +export { Action, Step, RequestType, PushType }; diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index b347a1043..c794d2fe6 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -17,7 +17,7 @@ import { Request, Response } from 'express'; import { PluginLoader } from '../plugin'; -import { Action, RequestType, ActionType } from './actions'; +import { Action, RequestType, PushType } from './actions'; import * as proc from './processors'; import { attemptAutoApproval, attemptAutoRejection } from './actions/autoActions'; import { handleErrorAndLog } from '../utils/errors'; @@ -146,7 +146,7 @@ export const getChain = async ( case RequestType.PULL: return pullActionChain; case RequestType.PUSH: - return action.actionType === ActionType.TAG ? tagPushChain : branchPushChain; + return action.actionType === PushType.TAG ? tagPushChain : branchPushChain; default: return defaultActionChain; } diff --git a/src/proxy/processors/push-action/parsePush.ts b/src/proxy/processors/push-action/parsePush.ts index 28cab6819..9ed65b0bf 100644 --- a/src/proxy/processors/push-action/parsePush.ts +++ b/src/proxy/processors/push-action/parsePush.ts @@ -19,7 +19,7 @@ import fs from 'fs'; import lod from 'lodash'; import { createInflate } from 'zlib'; -import { Action, Step, ActionType } from '../../actions'; +import { Action, Step, PushType } from '../../actions'; import { CommitContent, CommitData, CommitHeader, PackMeta, PersonLine } from '../types'; import { TagData } from '../../../types/models'; import { @@ -95,10 +95,10 @@ async function exec(req: Request, action: Action): Promise { } if (allTags) { - action.actionType = ActionType.TAG; + action.actionType = PushType.TAG; action.tags = parsedRefs.map((r) => r.refName); } else { - action.actionType = ActionType.BRANCH; + action.actionType = PushType.BRANCH; action.branch = parsedRefs[0].refName; } @@ -129,7 +129,7 @@ async function exec(req: Request, action: Action): Promise { else if (obj.type === GIT_OBJECT_TYPE_TAG) action.tagData.push(getTagData(obj)); } - if (action.actionType === ActionType.TAG) { + if (action.actionType === PushType.TAG) { if (action.tagData.length) { action.user = action.tagData.at(-1)!.tagger; action.userEmail = action.tagData.at(-1)!.taggerEmail; @@ -139,7 +139,7 @@ async function exec(req: Request, action: Action): Promise { 'Lightweight (non-annotated) tags are not supported. Please use "git tag -a" to create an annotated tag.', ); } - } else if (action.actionType === ActionType.BRANCH) { + } else if (action.actionType === PushType.BRANCH) { if (action.commitData.length && action.commitFrom === EMPTY_COMMIT_HASH) { action.commitFrom = action.commitData[action.commitData.length - 1].parent; } diff --git a/test/chain.test.ts b/test/chain.test.ts index 516470e1b..18cda2493 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, ActionType, RequestType } from '../src/proxy/actions'; +import { Action, PushType, RequestType } from '../src/proxy/actions'; const mockLoader = { pushPlugins: [ @@ -465,7 +465,7 @@ describe('proxy chain', function () { Date.now(), 'https://github.com/owner/repo.git', ); - action.actionType = ActionType.TAG; + action.actionType = PushType.TAG; const tagChain = await chain.getChain(action); expect(tagChain).toEqual(chain.tagPushChain); }); @@ -478,14 +478,14 @@ describe('proxy chain', function () { Date.now(), 'https://github.com/owner/repo.git', ); - action.actionType = ActionType.BRANCH; + 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: ActionType.TAG }); + 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); @@ -494,7 +494,7 @@ describe('proxy chain', function () { 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: ActionType.TAG }); + const actual = await chain.getChain({ type: RequestType.PUSH, actionType: PushType.TAG }); expect(actual.length).toBeGreaterThan(initialChain.length); expect(chain.pluginsInserted).toBe(true); }); From 07fd5b3c7ced6300bc5c769cd765001a8b7e18ec Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Mon, 15 Jun 2026 14:28:57 +0200 Subject: [PATCH 24/27] fix(cypress): use tags array in test tag push mock data --- cypress/support/commands.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cypress/support/commands.js b/cypress/support/commands.js index ab8b97b47..c90f5a4a4 100644 --- a/cypress/support/commands.js +++ b/cypress/support/commands.js @@ -116,7 +116,7 @@ Cypress.Commands.add('createTestTagPush', (pushData = {}) => { user: 'test-tagger', userEmail: 'test-tagger@test.com', branch: 'refs/heads/main', - tag: 'refs/tags/v1.0.0', + tags: ['refs/tags/v1.0.0'], commitFrom: '0000000000000000000000000000000000000000', commitTo: 'abcdef1234567890abcdef1234567890abcdef12', lastStep: null, From 9b2deb3b41b6f6c6ffd650d0179d824d5253cfe8 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Mon, 15 Jun 2026 14:45:49 +0200 Subject: [PATCH 25/27] refactor(chain): use Processor['exec'] indexed type for chain arrays --- src/proxy/chain.ts | 17 ++++++----------- 1 file changed, 6 insertions(+), 11 deletions(-) diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index c794d2fe6..e140a88f7 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -19,10 +19,11 @@ import { Request, Response } from 'express'; import { PluginLoader } from '../plugin'; 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 branchPushChain: ((req: Request, action: Action) => Promise)[] = [ +const branchPushChain: Processor['exec'][] = [ proc.push.checkEmptyBranch, proc.push.checkRepoInAuthorisedList, proc.push.checkMessages, @@ -39,7 +40,7 @@ const branchPushChain: ((req: Request, action: Action) => Promise)[] = [ proc.push.blockForAuth, ]; -const tagPushChain: ((req: Request, action: Action) => Promise)[] = [ +const tagPushChain: Processor['exec'][] = [ proc.push.checkRepoInAuthorisedList, proc.push.checkUserPushPermission, proc.push.checkIfWaitingAuth, @@ -50,13 +51,9 @@ const tagPushChain: ((req: Request, action: Action) => Promise)[] = [ proc.push.blockForAuth, ]; -const pullActionChain: ((req: Request, action: Action) => Promise)[] = [ - proc.push.checkRepoInAuthorisedList, -]; +const pullActionChain: Processor['exec'][] = [proc.push.checkRepoInAuthorisedList]; -const defaultActionChain: ((req: Request, action: Action) => Promise)[] = [ - proc.push.checkRepoInAuthorisedList, -]; +const defaultActionChain: Processor['exec'][] = [proc.push.checkRepoInAuthorisedList]; let pluginsInserted = false; @@ -113,9 +110,7 @@ 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...', From b410b7a061232a2c7c4f394da7c9bf00df03b311 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 17 Jun 2026 11:14:52 +0200 Subject: [PATCH 26/27] fix(ui): fix errored tab query mismatch and add null safety for tag pushes --- src/ui/utils/pushUtils.ts | 13 ++++++++----- .../views/PushRequests/components/PushesTable.tsx | 2 +- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/src/ui/utils/pushUtils.ts b/src/ui/utils/pushUtils.ts index 919bedee2..97ac47efb 100644 --- a/src/ui/utils/pushUtils.ts +++ b/src/ui/utils/pushUtils.ts @@ -79,7 +79,10 @@ export const getRefToShow = (pushData: PushData): string => { if (isTagPush(pushData)) { return getTagName(pushData.tags); } - return trimPrefixRefsHeads(pushData.branch); + if (pushData?.tags && pushData.tags.length > 0) { + return getTagName(pushData.tags); + } + return trimPrefixRefsHeads(pushData.branch || ''); }; /** @@ -131,7 +134,7 @@ export const getAuthor = (pushData: PushData): string => { if (isTagPush(pushData)) { return pushData.tagData?.[0]?.tagger || 'N/A'; } - return pushData.commitData[0]?.author || 'N/A'; + return pushData.commitData?.[0]?.author || 'N/A'; }; /** @@ -143,7 +146,7 @@ export const getAuthorEmail = (pushData: PushData): string => { if (isTagPush(pushData)) { return pushData.tagData?.[0]?.taggerEmail || 'N/A'; } - return pushData.commitData[0]?.authorEmail || 'N/A'; + return pushData.commitData?.[0]?.authorEmail || 'N/A'; }; /** @@ -154,9 +157,9 @@ export const getAuthorEmail = (pushData: PushData): string => { 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.tagData?.[0]?.message || pushData.commitData?.[0]?.message || ''; } - return pushData.commitData[0]?.message || 'N/A'; + return pushData.commitData?.[0]?.message || 'N/A'; }; /** diff --git a/src/ui/views/PushRequests/components/PushesTable.tsx b/src/ui/views/PushRequests/components/PushesTable.tsx index c950d14b5..e335ee079 100644 --- a/src/ui/views/PushRequests/components/PushesTable.tsx +++ b/src/ui/views/PushRequests/components/PushesTable.tsx @@ -77,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); From b132529e224cd45dfea859f90b4e02fe0abfa88e Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Fri, 19 Jun 2026 10:18:06 +0200 Subject: [PATCH 27/27] refactor(chain): move parsePush to pre-processor --- src/proxy/chain.ts | 9 +++--- src/proxy/processors/pre-processor/index.ts | 2 ++ .../parsePush.ts | 7 ++--- src/proxy/processors/push-action/index.ts | 2 -- test/chain.test.ts | 29 ++++++++++++------- test/testParsePush.test.ts | 2 +- 6 files changed, 29 insertions(+), 22 deletions(-) rename src/proxy/processors/{push-action => pre-processor}/parsePush.ts (99%) diff --git a/src/proxy/chain.ts b/src/proxy/chain.ts index e140a88f7..ba58b3f4c 100644 --- a/src/proxy/chain.ts +++ b/src/proxy/chain.ts @@ -64,9 +64,9 @@ export const executeChain = async (req: Request, _res: Response): Promise => ); 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 - branchPushChain.splice(1, 0, pluginObj.exec); - tagPushChain.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`); 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/push-action/parsePush.ts b/src/proxy/processors/pre-processor/parsePush.ts similarity index 99% rename from src/proxy/processors/push-action/parsePush.ts rename to src/proxy/processors/pre-processor/parsePush.ts index 9ed65b0bf..dc003f1c6 100644 --- a/src/proxy/processors/push-action/parsePush.ts +++ b/src/proxy/processors/pre-processor/parsePush.ts @@ -23,9 +23,9 @@ import { Action, Step, PushType } from '../../actions'; import { CommitContent, CommitData, CommitHeader, PackMeta, PersonLine } from '../types'; import { TagData } from '../../../types/models'; import { + EMPTY_COMMIT_HASH, REFS_PREFIX, TAG_PREFIX, - EMPTY_COMMIT_HASH, PACK_SIGNATURE, PACKET_SIZE, GIT_OBJECT_TYPE_COMMIT, @@ -63,7 +63,9 @@ 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(REFS_PREFIX)); if (refUpdates.length === 0) { @@ -102,10 +104,8 @@ async function exec(req: Request, action: Action): Promise { action.branch = parsedRefs[0].refName; } - // Use the first ref's commit range for the action id action.setCommit(parsedRefs[0].oldCommit, parsedRefs[0].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.'); @@ -113,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.'); diff --git a/src/proxy/processors/push-action/index.ts b/src/proxy/processors/push-action/index.ts index 6387aca20..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'; @@ -33,7 +32,6 @@ import { exec as checkUserPushPermission } from './checkUserPushPermission'; import { exec as checkEmptyBranch } from './checkEmptyBranch'; export { - parsePush, preReceive, checkRepoInAuthorisedList, pullRemote, diff --git a/test/chain.test.ts b/test/chain.test.ts index 18cda2493..215da7632 100644 --- a/test/chain.test.ts +++ b/test/chain.test.ts @@ -29,7 +29,6 @@ const mockLoader = { const initMockPushProcessors = () => { return { - parsePush: vi.fn(), checkEmptyBranch: vi.fn(), checkRepoInAuthorisedList: vi.fn(), checkMessages: vi.fn(), @@ -57,6 +56,7 @@ const initMockPostProcessors = () => { const mockPreProcessors = { parseAction: vi.fn(), + parsePush: vi.fn(), }; describe('proxy chain', function () { @@ -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,7 +153,8 @@ 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.checkMessages).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,7 +199,8 @@ 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.checkMessages).toHaveBeenCalled(); @@ -230,13 +232,14 @@ 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.checkMessages).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, diff --git a/test/testParsePush.test.ts b/test/testParsePush.test.ts index 405a33b63..8ca551011 100644 --- a/test/testParsePush.test.ts +++ b/test/testParsePush.test.ts @@ -26,7 +26,7 @@ import { getContents, getPackMeta, getTagData, -} from '../src/proxy/processors/push-action/parsePush'; +} 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';