Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 9 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -72,12 +72,12 @@ jobs:
env:
RUN_MONGO_TESTS: 'true'
GIT_PROXY_MONGO_CONNECTION_STRING: mongodb://localhost:27017/git-proxy-test
run: npm run test:integration
run: npm run test:integration -- --coverage --coverage.reporter=lcovonly --coverage.reportsDirectory=coverage/integration

- name: Upload test coverage report
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
files: ./coverage/lcov.info,./coverage-cli/lcov.info
files: ./coverage/lcov.info,./coverage/integration/lcov.info,./coverage-cli/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}

- name: Build frontend
Expand Down Expand Up @@ -140,7 +140,13 @@ jobs:
env:
RUN_POSTGRES_TESTS: 'true'
GIT_PROXY_POSTGRES_CONNECTION_STRING: postgresql://postgres:postgres@localhost:5432/git_proxy_test
run: npm run test:integration:postgres
run: npm run test:integration:postgres -- --coverage --coverage.reporter=lcovonly --coverage.reportsDirectory=coverage/postgres

- name: Upload PostgreSQL coverage report
uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
with:
files: ./coverage/postgres/lcov.info
token: ${{ secrets.CODECOV_TOKEN }}

# Windows build - single combination for development support
build-windows:
Expand Down
1 change: 1 addition & 0 deletions cypress/e2e/autoApproved.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,7 @@ describe('Auto-Approved Push Test', () => {
cy.wait('@getPush');

cy.contains('Auto-approved by system').should('be.visible');
cy.contains('Invalid DateTime').should('not.exist');

cy.contains('approved this contribution').should('not.exist');
});
Expand Down
23 changes: 17 additions & 6 deletions cypress/e2e/docker/pushActions.cy.js
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,8 @@ describe('Push Actions (Approve, Reject, Cancel)', () => {

it('should approve a pending push via attestation dialog', function () {
cy.login(approverUser.username, approverUser.password);
cy.visit(`/dashboard/push/${this.pushId}`);
cy.visit('/dashboard/push');
cy.get(`tr[aria-label="Open activity ${this.pushId}"]`).click();

// Verify push is Pending
cy.get('[data-testid="push-status"]').should('contain', 'Pending');
Expand Down Expand Up @@ -95,10 +96,13 @@ describe('Push Actions (Approve, Reject, Cancel)', () => {
// Should navigate back to push list
cy.url().should('include', '/dashboard/push');
cy.url().should('not.include', this.pushId);
cy.get(`tr[aria-label="Open activity ${this.pushId}"]`).should('not.exist');
cy.contains('a', 'Approved').click();
cy.get(`tr[aria-label="Open activity ${this.pushId}"]`).click();

// Verify push is now Approved by revisiting its detail page
cy.visit(`/dashboard/push/${this.pushId}`);
cy.get('[data-testid="push-status"]').should('contain', 'Approved');
cy.contains('Invalid DateTime').should('not.exist');

// Action buttons should no longer be visible for an approved push
cy.get('[data-testid="push-cancel-btn"]').should('not.exist');
Expand All @@ -115,7 +119,8 @@ describe('Push Actions (Approve, Reject, Cancel)', () => {

it('should reject a pending push', function () {
cy.login(approverUser.username, approverUser.password);
cy.visit(`/dashboard/push/${this.pushId}`);
cy.visit('/dashboard/push');
cy.get(`tr[aria-label="Open activity ${this.pushId}"]`).click();

// Verify push is Pending
cy.get('[data-testid="push-status"]').should('contain', 'Pending');
Expand All @@ -140,8 +145,11 @@ describe('Push Actions (Approve, Reject, Cancel)', () => {
cy.url().should('not.include', this.pushId);

// Verify push is now Rejected
cy.visit(`/dashboard/push/${this.pushId}`);
cy.get(`tr[aria-label="Open activity ${this.pushId}"]`).should('not.exist');
cy.contains('a', 'Rejected').click();
cy.get(`tr[aria-label="Open activity ${this.pushId}"]`).click();
cy.get('[data-testid="push-status"]').should('contain', 'Rejected');
cy.contains('Invalid DateTime').should('not.exist');

// Action buttons should no longer be visible
cy.get('[data-testid="push-cancel-btn"]').should('not.exist');
Expand All @@ -159,7 +167,8 @@ describe('Push Actions (Approve, Reject, Cancel)', () => {
it('should cancel a pending push', function () {
// Cancel can be done by the push author
cy.login(testUser.username, testUser.password);
cy.visit(`/dashboard/push/${this.pushId}`);
cy.visit('/dashboard/push');
cy.get(`tr[aria-label="Open activity ${this.pushId}"]`).click();

// Verify push is Pending
cy.get('[data-testid="push-status"]').should('contain', 'Pending');
Expand All @@ -171,7 +180,9 @@ describe('Push Actions (Approve, Reject, Cancel)', () => {
cy.url().should('include', '/dashboard/push');

// Verify push is now Canceled
cy.visit(`/dashboard/push/${this.pushId}`);
cy.get(`tr[aria-label="Open activity ${this.pushId}"]`).should('not.exist');
cy.contains('a', 'Canceled').click();
cy.get(`tr[aria-label="Open activity ${this.pushId}"]`).click();
cy.get('[data-testid="push-status"]').should('contain', 'Canceled');

// Action buttons should no longer be visible
Expand Down
3 changes: 1 addition & 2 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ console.log('Setting up the proxy and Service');

// The deferred imports should cause these to be loaded on first access
const proxy = new Proxy();
proxy.start();
Service.start(proxy);
void proxy.start().then(() => Service.start(proxy));

export { proxy, Service };
1 change: 1 addition & 0 deletions src/db/file/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const {
getPushes,
getPushesForUserProfile,
getRepoPushRollupsByCanonicalUrl,
rebuildRepoPushRollups,
writeAudit,
getPush,
deletePush,
Expand Down
133 changes: 36 additions & 97 deletions src/db/file/pushes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,16 +16,15 @@

import _ from 'lodash';
import Datastore from '@seald-io/nedb';
import { activityPrimaryStatusFromFlags } from '../../activity/activityPrimaryStatus';
import { canonicalRemoteUrl } from '../../activity/canonicalRemoteUrl';
import { Action } from '../../proxy/actions/Action';
import { toClass } from '../helper';
import { PushQuery, RepoPushRollupsByCanonicalUrl } from '../types';
import {
PushQuery,
RepoActivityTabCounts,
RepoPushRollupsByCanonicalUrl,
emptyRepoActivityTabCounts,
} from '../types';
RepoActivityIndex,
pushActivity,
pushActivityProjection,
type PushActivityRow,
} from '../repoActivity';
import { CompletedAttestation, Rejection } from '../../proxy/processors/types';
import { handleErrorAndLog } from '../../utils/errors';
import { buildUserProfilePushFilter } from '../userProfilePushQuery';
Expand All @@ -49,97 +48,34 @@ try {
}
db.setAutocompactionInterval(COMPACTION_INTERVAL);

function bumpCount(
m: Map<string, RepoActivityTabCounts>,
canonicalKey: string,
tab: keyof RepoActivityTabCounts,
): void {
if (!canonicalKey) {
return;
}
let row = m.get(canonicalKey);
if (!row) {
row = emptyRepoActivityTabCounts();
m.set(canonicalKey, row);
}
row[tab] += 1;
}

function bumpMaxTimestampMs(
m: Map<string, number>,
canonicalKey: string,
timestamp: unknown,
): void {
if (!canonicalKey) {
return;
}
const ts = typeof timestamp === 'number' ? timestamp : NaN;
if (!Number.isFinite(ts)) {
return;
}
const prev = m.get(canonicalKey);
if (prev === undefined || ts > prev) {
m.set(canonicalKey, ts);
}
}

type PushActivityProjection = {
url?: string;
error?: boolean;
rejected?: boolean;
canceled?: boolean;
authorised?: boolean;
blocked?: boolean;
allowPush?: boolean;
timestamp?: number;
};

export const getRepoPushRollupsByCanonicalUrl = (): Promise<RepoPushRollupsByCanonicalUrl> => {
return new Promise((resolve, reject) => {
db.find(
{ type: 'push' },
{
url: 1,
error: 1,
rejected: 1,
canceled: 1,
authorised: 1,
blocked: 1,
allowPush: 1,
timestamp: 1,
},
(err, docs: PushActivityProjection[]) => {
if (err) {
reject(err);
return;
}
const tabCounts = new Map<string, RepoActivityTabCounts>();
const latestPendingReviewAtMs = new Map<string, number>();
const latestPushAtMs = new Map<string, number>();
for (const doc of docs) {
const url = typeof doc.url === 'string' ? doc.url : '';
const key = canonicalRemoteUrl(url);
if (!key) {
continue;
let activityIndex: RepoActivityIndex | undefined;
let activityIndexLoad: Promise<RepoActivityIndex> | undefined;

export const getRepoPushRollupsByCanonicalUrl =
async (): Promise<RepoPushRollupsByCanonicalUrl> => {
if (!activityIndexLoad) {
activityIndexLoad = new Promise<RepoActivityIndex>((resolve, reject) => {
db.find({}, pushActivityProjection, (err, docs: PushActivityRow[]) => {
if (err) {
reject(err);
return;
}
const tab = activityPrimaryStatusFromFlags({
error: doc.error === true,
rejected: doc.rejected === true,
canceled: doc.canceled === true,
authorised: doc.authorised === true,
blocked: doc.blocked === true,
allowPush: doc.allowPush === true,
});
bumpCount(tabCounts, key, tab);
bumpMaxTimestampMs(latestPushAtMs, key, doc.timestamp);
if (tab === 'pending') {
bumpMaxTimestampMs(latestPendingReviewAtMs, key, doc.timestamp);
}
}
resolve({ tabCounts, latestPendingReviewAtMs, latestPushAtMs });
},
);
});
activityIndex = new RepoActivityIndex();
for (const doc of docs) activityIndex.set(doc.id, pushActivity(doc));
resolve(activityIndex);
});
}).catch((error: unknown) => {
activityIndexLoad = undefined;
throw error;
});
}
return (await activityIndexLoad).snapshot();
};

export const rebuildRepoPushRollups = async (): Promise<RepoPushRollupsByCanonicalUrl> => {
await activityIndexLoad;
activityIndexLoad = undefined;
return getRepoPushRollupsByCanonicalUrl();
};

const defaultPushQuery: Partial<PushQuery> = {
Expand Down Expand Up @@ -228,6 +164,7 @@ export const deletePush = async (id: string): Promise<void> => {
if (err) {
reject(err);
} else {
activityIndex?.set(id, null);
resolve();
}
});
Expand All @@ -236,13 +173,15 @@ export const deletePush = async (id: string): Promise<void> => {

export const writeAudit = async (action: Action): Promise<void> => {
return new Promise((resolve, reject) => {
const contribution = pushActivity(action);
const options = { multi: false, upsert: true };
db.update({ id: action.id }, action, options, (err) => {
// ignore for code coverage as neDB rarely returns errors even for an invalid query
/* istanbul ignore if */
if (err) {
reject(err);
} else {
activityIndex?.set(action.id, contribution);
resolve();
}
});
Expand Down
1 change: 1 addition & 0 deletions src/db/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -212,6 +212,7 @@ export const canUserCancelPush = async (id: string, user: string) => {
};

export const runMigrations = (): Promise<void> => applyMigrations(start(), migrations);
export const rebuildRepoPushRollups = () => start().rebuildRepoPushRollups();
export const getSessionStore = (): MongoDBStore | Store | undefined => start().getSessionStore();
export const ensureSessionStoreReady = (): Promise<void> =>
start().ensureSessionStoreReady?.() ?? Promise.resolve();
Expand Down
2 changes: 2 additions & 0 deletions src/db/migrations/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@ import type { Migration } from './index';
import { populateRepoDates } from './populateRepoDates';
import { refactorLegacyPushIds } from './refactorLegacyPushIds';
import { gitAccountToScmIdentities } from './gitAccountToScmIdentities';
import { repositoryActivity } from './repositoryActivity';

export const migrations: Migration[] = [
populateRepoDates,
refactorLegacyPushIds,
gitAccountToScmIdentities,
repositoryActivity,
];
24 changes: 24 additions & 0 deletions src/db/migrations/repositoryActivity.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
/**
* 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 type { Migration } from './index';

export const repositoryActivity: Migration = {
id: '20261013-repository-activity',
up: async (sink) => {
await sink.rebuildRepoPushRollups();
},
};
6 changes: 4 additions & 2 deletions src/db/mongo/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,9 @@ export const resetConnection = async (): Promise<void> => {

export const getDb = (): Db | null => _db;

export const connect = async (collectionName: string): Promise<Collection> => {
export const connect = async <T extends Document = Document>(
collectionName: string,
): Promise<Collection<T>> => {
//retrieve config at point of use (rather than import)
const dbConfig = getDatabase();
const connectionString = dbConfig.connectionString;
Expand All @@ -53,7 +55,7 @@ export const connect = async (collectionName: string): Promise<Collection> => {
_db = _client.db();
}

return _db.collection(collectionName);
return _db.collection<T>(collectionName);
};

export const findDocuments = async <T>(
Expand Down
1 change: 1 addition & 0 deletions src/db/mongo/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ export const {
getPushes,
getPushesForUserProfile,
getRepoPushRollupsByCanonicalUrl,
rebuildRepoPushRollups,
writeAudit,
getPush,
deletePush,
Expand Down
Loading
Loading