From adc13628ea2bee97b82158d6512f533d62e055e8 Mon Sep 17 00:00:00 2001 From: fabiovincenzi Date: Wed, 11 Mar 2026 10:13:53 +0100 Subject: [PATCH 1/9] =?UTF-8?q?chore:=20fix=20eslintrc.json=20=E2=80=94=20?= =?UTF-8?q?remove=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 2/9] 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 3/9] 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 4/9] 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 5/9] 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 6/9] 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 7/9] 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(), + 'http://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(), + 'http://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(), + 'http://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 8/9] 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 9/9] 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",