Skip to content

Commit 61947df

Browse files
authored
Merge pull request #954 from fabiovincenzi/auto-approve
feat: implement auto approval with pre-receive hooks
2 parents 83e814b + c554cce commit 61947df

File tree

14 files changed

+521
-69
lines changed

14 files changed

+521
-69
lines changed

cypress/e2e/autoApproved.cy.js

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import moment from 'moment';
2+
3+
describe('Auto-Approved Push Test', () => {
4+
beforeEach(() => {
5+
cy.intercept('GET', '/api/v1/push/123', {
6+
statusCode: 200,
7+
body: {
8+
steps: [
9+
{
10+
stepName: 'diff',
11+
content: '',
12+
},
13+
],
14+
error: false,
15+
allowPush: true,
16+
authorised: true,
17+
canceled: false,
18+
rejected: false,
19+
autoApproved: true,
20+
autoRejected: false,
21+
commitFrom: 'commitFrom',
22+
commitTo: 'commitTo',
23+
branch: 'refs/heads/main',
24+
user: 'testUser',
25+
id: 'commitFrom__commitTo',
26+
type: 'push',
27+
method: 'POST',
28+
timestamp: 1696161600000,
29+
project: 'testUser',
30+
repoName: 'test.git',
31+
url: 'https://github.com/testUser/test.git',
32+
repo: 'testUser/test.git',
33+
commitData: [
34+
{
35+
tree: '1234',
36+
parent: '12345',
37+
},
38+
],
39+
attestation: {
40+
timestamp: '2023-10-01T12:00:00Z',
41+
autoApproved: true,
42+
},
43+
},
44+
}).as('getPush');
45+
});
46+
47+
it('should display auto-approved message and verify tooltip contains the expected timestamp', () => {
48+
cy.visit('/admin/push/123');
49+
50+
cy.wait('@getPush');
51+
52+
cy.contains('Auto-approved by system').should('be.visible');
53+
54+
cy.get('svg.MuiSvgIcon-root')
55+
.filter((_, el) => getComputedStyle(el).fill === 'rgb(0, 128, 0)')
56+
.invoke('attr', 'style')
57+
.should('include', 'cursor: default')
58+
.and('include', 'opacity: 0.5');
59+
60+
const expectedTooltipTimestamp = moment('2023-10-01T12:00:00Z')
61+
.local()
62+
.format('dddd, MMMM Do YYYY, h:mm:ss a');
63+
64+
cy.get('kbd')
65+
.trigger('mouseover')
66+
.then(() => {
67+
cy.get('.MuiTooltip-tooltip').should('contain', expectedTooltipTimestamp);
68+
});
69+
70+
cy.contains('approved this contribution').should('not.exist');
71+
});
72+
});

src/proxy/actions/Action.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ class Action {
1414
authorised = false;
1515
canceled = false;
1616
rejected = false;
17+
autoApproved = false;
18+
autoRejected = false;
1719
commitFrom;
1820
commitTo;
1921
branch;
@@ -104,6 +106,19 @@ class Action {
104106
this.blocked = false;
105107
}
106108

109+
/**
110+
*`
111+
*/
112+
setAutoApproval() {
113+
this.autoApproved = true;
114+
}
115+
116+
/**
117+
*`
118+
*/
119+
setAutoRejection() {
120+
this.autoRejected = true;
121+
}
107122
/**
108123
* @return {bool}
109124
*/

src/proxy/actions/autoActions.js

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
const db = require('../../db');
2+
3+
const attemptAutoApproval = async (action) => {
4+
try {
5+
const attestation = {
6+
timestamp: new Date(),
7+
autoApproved: true,
8+
};
9+
await db.authorise(action.id, attestation);
10+
console.log('Push automatically approved by system.');
11+
12+
return true;
13+
} catch (error) {
14+
console.error('Error during auto-approval:', error.message);
15+
return false;
16+
}
17+
};
18+
19+
const attemptAutoRejection = async (action) => {
20+
try {
21+
const attestation = {
22+
timestamp: new Date(),
23+
autoApproved: true,
24+
};
25+
await db.reject(action.id, attestation);
26+
console.log('Push automatically rejected by system.');
27+
28+
return true;
29+
} catch (error) {
30+
console.error('Error during auto-rejection:', error.message);
31+
return false;
32+
}
33+
};
34+
35+
module.exports = {
36+
attemptAutoApproval,
37+
attemptAutoRejection,
38+
};

src/proxy/chain.js

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
const proc = require('./processors');
2+
const { attemptAutoApproval, attemptAutoRejection } = require('./actions/autoActions');
23

34
const pushActionChain = [
45
proc.push.parsePush,
@@ -40,6 +41,11 @@ const executeChain = async (req) => {
4041
}
4142
} finally {
4243
await proc.push.audit(req, action);
44+
if (action.autoApproved) {
45+
attemptAutoApproval(action);
46+
} else if (action.autoRejected) {
47+
attemptAutoRejection(action);
48+
}
4349
}
4450

4551
return action;

src/proxy/processors/push-action/preReceive.js

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ const sanitizeInput = (_req, action) => {
99

1010
const exec = async (req, action, hookFilePath = './hooks/pre-receive.sh') => {
1111
const step = new Step('executeExternalPreReceiveHook');
12+
let stderrTrimmed = '';
1213

1314
try {
1415
const resolvedPath = path.resolve(hookFilePath);
@@ -34,23 +35,33 @@ const exec = async (req, action, hookFilePath = './hooks/pre-receive.sh') => {
3435

3536
const { stdout, stderr, status } = hookProcess;
3637

37-
const stderrTrimmed = stderr ? stderr.trim() : '';
38+
stderrTrimmed = stderr ? stderr.trim() : '';
3839
const stdoutTrimmed = stdout ? stdout.trim() : '';
3940

40-
if (status !== 0) {
41+
step.log(`Hook exited with status ${status}`);
42+
43+
if (status === 0) {
44+
step.log('Push automatically approved by pre-receive hook.');
45+
action.addStep(step);
46+
action.setAutoApproval();
47+
} else if (status === 1) {
48+
step.log('Push automatically rejected by pre-receive hook.');
49+
action.addStep(step);
50+
action.setAutoRejection();
51+
} else if (status === 2) {
52+
step.log('Push requires manual approval.');
53+
action.addStep(step);
54+
} else {
4155
step.error = true;
42-
step.log(`Hook stderr: ${stderrTrimmed}`);
43-
step.setError(stdoutTrimmed);
56+
step.log(`Unexpected hook status: ${status}`);
57+
step.setError(stdoutTrimmed || 'Unknown pre-receive hook error.');
4458
action.addStep(step);
45-
return action;
4659
}
47-
48-
step.log('Pre-receive hook executed successfully');
49-
action.addStep(step);
5060
return action;
5161
} catch (error) {
5262
step.error = true;
53-
step.setError(`Hook execution error: ${error.message}`);
63+
step.log('Push failed, pre-receive hook returned an error.');
64+
step.setError(`Hook execution error: ${stderrTrimmed || error.message}`);
5465
action.addStep(step);
5566
return action;
5667
}

src/ui/views/PushDetails/PushDetails.jsx

Lines changed: 57 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -173,45 +173,67 @@ export default function Dashboard() {
173173
}}
174174
>
175175
<CheckCircle
176-
style={{ cursor: 'pointer', transform: 'scale(0.65)' }}
177-
onClick={() => setAttestation(true)}
176+
style={{
177+
cursor: data.autoApproved ? 'default' : 'pointer',
178+
transform: 'scale(0.65)',
179+
opacity: data.autoApproved ? 0.5 : 1,
180+
}}
181+
onClick={() => {
182+
if (!data.autoApproved) {
183+
setAttestation(true);
184+
}
185+
}}
178186
htmlColor='green'
179187
/>
180188
</span>
181-
<a href={`/admin/user/${data.attestation.reviewer.username}`}>
182-
<img
183-
style={{ width: '45px', borderRadius: '20px' }}
184-
src={`https://github.com/${data.attestation.reviewer.gitAccount}.png`}
185-
/>
186-
</a>
187-
<div>
188-
<p>
189+
190+
{data.autoApproved ? (
191+
<>
192+
<div style={{ paddingTop: '15px' }}>
193+
<p>
194+
<strong>Auto-approved by system</strong>
195+
</p>
196+
</div>
197+
</>
198+
) : (
199+
<>
189200
<a href={`/admin/user/${data.attestation.reviewer.username}`}>
190-
{data.attestation.reviewer.gitAccount}
191-
</a>{' '}
192-
approved this contribution
193-
</p>
194-
<Tooltip
195-
title={moment(data.attestation.timestamp).format(
196-
'dddd, MMMM Do YYYY, h:mm:ss a',
197-
)}
198-
arrow
199-
>
200-
<kbd
201-
style={{
202-
color: 'black',
203-
float: 'right',
204-
}}
205-
>
206-
{moment(data.attestation.timestamp).fromNow()}
207-
</kbd>
208-
</Tooltip>
209-
</div>
210-
<AttestationView
211-
data={data.attestation}
212-
attestation={attestation}
213-
setAttestation={setAttestation}
214-
/>
201+
<img
202+
style={{ width: '45px', borderRadius: '20px' }}
203+
src={`https://github.com/${data.attestation.reviewer.gitAccount}.png`}
204+
/>
205+
</a>
206+
<div>
207+
<p>
208+
<a href={`/admin/user/${data.attestation.reviewer.username}`}>
209+
{data.attestation.reviewer.gitAccount}
210+
</a>{' '}
211+
approved this contribution
212+
</p>
213+
</div>
214+
</>
215+
)}
216+
217+
<Tooltip
218+
title={moment(data.attestation.timestamp).format(
219+
'dddd, MMMM Do YYYY, h:mm:ss a',
220+
)}
221+
arrow
222+
>
223+
<kbd style={{ color: 'black', float: 'right' }}>
224+
{moment(data.attestation.timestamp).fromNow()}
225+
</kbd>
226+
</Tooltip>
227+
228+
{data.autoApproved ? (
229+
<></>
230+
) : (
231+
<AttestationView
232+
data={data.attestation}
233+
attestation={attestation}
234+
setAttestation={setAttestation}
235+
/>
236+
)}
215237
</div>
216238
) : null}
217239
</CardHeader>

0 commit comments

Comments
 (0)