Skip to content

Commit 96a391f

Browse files
committed
feat: add Yardstick URL to review workflow
Add a Yardstick deep-link URL field to the review request modal so reviewers know where to submit their review results. Validate the URL requires page=hackerrank, candidate, and zohoId query params. Reformat the acceptance message to be more scannable with action links at top.
1 parent d9f6632 commit 96a391f

15 files changed

Lines changed: 233 additions & 29 deletions

src/bot/__tests__/acceptReviewRequest.test.ts

Lines changed: 10 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -20,32 +20,25 @@ describe('acceptReviewRequest', () => {
2020
activeReviewRepo.getReviewByThreadIdOrUndefined = jest.fn();
2121
});
2222

23-
const expectedHackerRankUrlBlock = {
23+
const expectedLinksBlock = {
2424
type: 'section',
2525
text: {
2626
type: 'mrkdwn',
27-
text: '*HackerRank Report:* <https://www.sourceallies.com|View Candidate Assessment>',
27+
text: '*:paperclip: <https://www.sourceallies.com|View Candidate Assessment>*\n*:paperclip: <https://yardstick.example.com|Submit Review Results>*',
2828
},
2929
};
30-
const expectedHackerRankInstructionsBlock = {
30+
const expectedInstructionsBlock = {
3131
type: 'section',
3232
text: {
3333
type: 'mrkdwn',
34-
text: '_To review the candidate\u2019s test, visit the URL above and log in with your Source Allies HackerRank account. If you have questions about using HackerRank\u2019s review features, please visit our <https://allies.atlassian.net/wiki/spaces/REI/pages/4868112402/Helpful+HackerRank+Features|documentation>._',
35-
},
36-
};
37-
const expectedHackerRankAccountHelpBlock = {
38-
type: 'section',
39-
text: {
40-
type: 'mrkdwn',
41-
text: "_Don't have a HackerRank account? Ping <@requester123> and they'll make one for you._",
34+
text: "_Log in with your SA HackerRank account._\n_No account? Ping <@requester123> and they'll make one for you._\n_Questions? See our <https://allies.atlassian.net/wiki/spaces/REI/pages/4868112402/Helpful+HackerRank+Features|documentation>._",
4235
},
4336
};
4437
const expectedTestInfoBlock = {
4538
type: 'section',
4639
text: {
4740
type: 'mrkdwn',
48-
text: '*Test Information:*\nThe test has 4 questions: 2 easy and 2 medium difficulty.\nSection 1 contains the easy questions, Section 2 contains the medium questions.\nCandidates should try to solve one problem from each section.\nThey have 70 minutes total to complete the test.',
41+
text: '*Test Info:* 4 questions (2 easy, 2 medium) · 70 min · candidates answer 1 per section (2 total)',
4942
},
5043
};
5144

@@ -77,6 +70,7 @@ describe('acceptReviewRequest', () => {
7770
// Mock review with user in pending list
7871
(activeReviewRepo.getReviewByThreadIdOrUndefined as jest.Mock).mockResolvedValue({
7972
hackerRankUrl: 'https://www.sourceallies.com',
73+
yardstickUrl: 'https://yardstick.example.com',
8074
requestorId: 'requester123',
8175
candidateType: CandidateType.FULL_TIME,
8276
acceptedReviewers: [],
@@ -134,9 +128,8 @@ describe('acceptReviewRequest', () => {
134128
);
135129
expectUpdatedWithBlocks(
136130
action,
137-
expectedHackerRankUrlBlock,
138-
expectedHackerRankInstructionsBlock,
139-
expectedHackerRankAccountHelpBlock,
131+
expectedLinksBlock,
132+
expectedInstructionsBlock,
140133
expectedTestInfoBlock,
141134
);
142135
expect(reviewCloser.closeReviewIfComplete).toHaveBeenCalledWith(app, '123');
@@ -162,6 +155,7 @@ describe('acceptReviewRequest', () => {
162155
// Mock review where user already accepted (not in pending)
163156
(activeReviewRepo.getReviewByThreadIdOrUndefined as jest.Mock).mockResolvedValue({
164157
hackerRankUrl: 'https://www.sourceallies.com',
158+
yardstickUrl: 'https://yardstick.example.com',
165159
acceptedReviewers: [{ userId }],
166160
declinedReviewers: [],
167161
pendingReviewers: [],
@@ -202,6 +196,7 @@ describe('acceptReviewRequest', () => {
202196
// Mock review where user already declined (not in pending)
203197
(activeReviewRepo.getReviewByThreadIdOrUndefined as jest.Mock).mockResolvedValue({
204198
hackerRankUrl: 'https://www.sourceallies.com',
199+
yardstickUrl: 'https://yardstick.example.com',
205200
acceptedReviewers: [],
206201
declinedReviewers: [{ userId }],
207202
pendingReviewers: [],

src/bot/__tests__/getReviewInfo.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ describe('getReviewInfo', () => {
5353
declinedReviewers: [],
5454
pendingReviewers: [],
5555
hackerRankUrl: '',
56+
yardstickUrl: '',
5657
};
5758
activeReviewRepo.getReviewByThreadIdOrFail = jest.fn().mockResolvedValue(review);
5859
userRepo.listAll = jest.fn().mockResolvedValue([]);

src/bot/__tests__/requestReview.test.ts

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -184,6 +184,27 @@ describe('requestReview', () => {
184184
},
185185
});
186186
});
187+
188+
it('should setup the seventh response block for the Yardstick URL input', () => {
189+
const { mock } = param.client.views.open as jest.Mock;
190+
const blocks = mock.calls[0][0].view.blocks;
191+
expect(blocks[6]).toEqual({
192+
type: 'input',
193+
block_id: ActionId.YARDSTICK_URL,
194+
label: {
195+
text: 'Yardstick URL',
196+
type: 'plain_text',
197+
},
198+
element: {
199+
type: 'plain_text_input',
200+
action_id: ActionId.YARDSTICK_URL,
201+
placeholder: {
202+
text: 'Enter Yardstick URL...',
203+
type: 'plain_text',
204+
},
205+
},
206+
});
207+
});
187208
});
188209

189210
describe('when the language cannot be retrieved', () => {
@@ -287,6 +308,13 @@ describe('requestReview', () => {
287308
value: 'https://www.hackerrank.com/test/example123?authkey=validkey123',
288309
},
289310
},
311+
[ActionId.YARDSTICK_URL]: {
312+
[ActionId.YARDSTICK_URL]: {
313+
type: 'plain_text_input',
314+
value:
315+
'https://script.google.com/a/sourceallies.com/macros/s/abc123/exec?page=hackerrank&candidate=John+Doe&zohoId=12345',
316+
},
317+
},
290318
};
291319

292320
async function callCallback(param = buildParam()) {
@@ -385,6 +413,8 @@ _Candidate Identifier: some-identifier_
385413
},
386414
],
387415
hackerRankUrl: 'https://www.hackerrank.com/test/example123?authkey=validkey123',
416+
yardstickUrl:
417+
'https://script.google.com/a/sourceallies.com/macros/s/abc123/exec?page=hackerrank&candidate=John+Doe&zohoId=12345',
388418
});
389419
});
390420

@@ -439,6 +469,65 @@ _Candidate Identifier: some-identifier_
439469
expect(param.client.chat.postMessage).toHaveBeenCalled();
440470
});
441471

472+
it('should reject submission when Yardstick URL is invalid', async () => {
473+
const invalidUrlValues = {
474+
...defaultValues,
475+
[ActionId.YARDSTICK_URL]: {
476+
[ActionId.YARDSTICK_URL]: {
477+
type: 'plain_text_input',
478+
value: 'https://example.com/not-yardstick',
479+
},
480+
},
481+
};
482+
483+
const param = buildParam(invalidUrlValues);
484+
await requestReview.callback(param);
485+
486+
expect(param.ack).toHaveBeenCalledWith({
487+
response_action: 'errors',
488+
errors: {
489+
[ActionId.YARDSTICK_URL]:
490+
'Please provide a valid Yardstick URL with page=hackerrank, candidate, and zohoId parameters.',
491+
},
492+
});
493+
494+
expect(activeReviewRepo.create).not.toHaveBeenCalled();
495+
expect(param.client.chat.postMessage).not.toHaveBeenCalled();
496+
});
497+
498+
it('should return errors for both URLs when both are invalid', async () => {
499+
const invalidUrlValues = {
500+
...defaultValues,
501+
[ActionId.HACKERRANK_URL]: {
502+
[ActionId.HACKERRANK_URL]: {
503+
type: 'plain_text_input',
504+
value: 'not-a-valid-url',
505+
},
506+
},
507+
[ActionId.YARDSTICK_URL]: {
508+
[ActionId.YARDSTICK_URL]: {
509+
type: 'plain_text_input',
510+
value: 'also-not-valid',
511+
},
512+
},
513+
};
514+
515+
const param = buildParam(invalidUrlValues);
516+
await requestReview.callback(param);
517+
518+
expect(param.ack).toHaveBeenCalledWith({
519+
response_action: 'errors',
520+
errors: {
521+
[ActionId.HACKERRANK_URL]:
522+
'Please provide a valid HackerRank URL with an authkey. Use the "Share Report" button to get the correct URL.',
523+
[ActionId.YARDSTICK_URL]:
524+
'Please provide a valid Yardstick URL with page=hackerrank, candidate, and zohoId parameters.',
525+
},
526+
});
527+
528+
expect(activeReviewRepo.create).not.toHaveBeenCalled();
529+
});
530+
442531
it('should reject submission when HackerRank URL is invalid format', async () => {
443532
const invalidUrlValues = {
444533
...defaultValues,

src/bot/acceptReviewRequest.ts

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,25 +83,22 @@ export const acceptReviewRequest = {
8383
const blocks = blockUtils.removeBlock(body, BlockId.REVIEWER_DM_BUTTONS);
8484
blocks.push(textBlock('*You accepted this review.*'));
8585

86-
// Add HackerRank URL with instructions if available
86+
// Add action links and instructions
8787
const review = await activeReviewRepo.getReviewByThreadIdOrUndefined(threadId);
8888
if (review) {
89-
blocks.push(
90-
textBlock(`*HackerRank Report:* <${review.hackerRankUrl}|View Candidate Assessment>`),
91-
);
9289
blocks.push(
9390
textBlock(
94-
'_To review the candidate\u2019s test, visit the URL above and log in with your Source Allies HackerRank account. If you have questions about using HackerRank\u2019s review features, please visit our <https://allies.atlassian.net/wiki/spaces/REI/pages/4868112402/Helpful+HackerRank+Features|documentation>._',
91+
`*:paperclip: <${review.hackerRankUrl}|View Candidate Assessment>*\n*:paperclip: <${review.yardstickUrl}|Submit Review Results>*`,
9592
),
9693
);
9794
blocks.push(
9895
textBlock(
99-
`_Don't have a HackerRank account? Ping ${mention({ id: review.requestorId })} and they'll make one for you._`,
96+
`_Log in with your SA HackerRank account._\n_No account? Ping ${mention({ id: review.requestorId })} and they'll make one for you._\n_Questions? See our <https://allies.atlassian.net/wiki/spaces/REI/pages/4868112402/Helpful+HackerRank+Features|documentation>._`,
10097
),
10198
);
10299
blocks.push(
103100
textBlock(
104-
'*Test Information:*\nThe test has 4 questions: 2 easy and 2 medium difficulty.\nSection 1 contains the easy questions, Section 2 contains the medium questions.\nCandidates should try to solve one problem from each section.\nThey have 70 minutes total to complete the test.',
101+
'*Test Info:* 4 questions (2 easy, 2 medium) · 70 min · candidates answer 1 per section (2 total)',
105102
),
106103
);
107104
await chatService.updateDirectMessage(client, user.id, messageTimestamp, blocks);

src/bot/enums.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ export const enum ActionId {
2323
REVIEWER_DM_ACCEPT = 'reviewer-dm-accept',
2424
REVIEWER_DM_DECLINE = 'reviewer-dm-deny',
2525
HACKERRANK_URL = 'hackerrank-url',
26+
YARDSTICK_URL = 'yardstick-url',
2627
}
2728

2829
export const enum BlockId {

src/bot/requestReview.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ import {
1919
} from './enums';
2020
import { chatService } from '@/services/ChatService';
2121
import { determineExpirationTime } from '@utils/reviewExpirationUtils';
22-
import { validateHackerRankUrl } from '@utils/urlValidation';
22+
import { validateHackerRankUrl, validateYardstickUrl } from '@utils/urlValidation';
2323

2424
export const requestReview = {
2525
app: undefined as unknown as App,
@@ -126,6 +126,22 @@ export const requestReview = {
126126
},
127127
},
128128
},
129+
{
130+
type: 'input',
131+
block_id: ActionId.YARDSTICK_URL,
132+
label: {
133+
text: 'Yardstick URL',
134+
type: 'plain_text',
135+
},
136+
element: {
137+
type: 'plain_text_input',
138+
action_id: ActionId.YARDSTICK_URL,
139+
placeholder: {
140+
text: 'Enter Yardstick URL...',
141+
type: 'plain_text',
142+
},
143+
},
144+
},
129145
];
130146

131147
return {
@@ -173,17 +189,26 @@ export const requestReview = {
173189
log.d('callback called for non-submit action');
174190
}
175191

176-
// Extract and validate HackerRank URL before acknowledging
192+
// Extract and validate URLs before acknowledging
177193
const hackerRankUrl = blockUtils.getBlockValue(body, ActionId.HACKERRANK_URL);
178194
const hackerRankUrlValue = hackerRankUrl.value;
195+
const yardstickUrl = blockUtils.getBlockValue(body, ActionId.YARDSTICK_URL);
196+
const yardstickUrlValue = yardstickUrl.value;
179197

198+
const errors: Record<string, string> = {};
180199
if (!validateHackerRankUrl(hackerRankUrlValue)) {
200+
errors[ActionId.HACKERRANK_URL] =
201+
'Please provide a valid HackerRank URL with an authkey. Use the "Share Report" button to get the correct URL.';
202+
}
203+
if (!validateYardstickUrl(yardstickUrlValue)) {
204+
errors[ActionId.YARDSTICK_URL] =
205+
'Please provide a valid Yardstick URL with page=hackerrank, candidate, and zohoId parameters.';
206+
}
207+
208+
if (Object.keys(errors).length > 0) {
181209
await ack({
182210
response_action: 'errors',
183-
errors: {
184-
[ActionId.HACKERRANK_URL]:
185-
'Please provide a valid HackerRank URL with an authkey. Use the "Share Report" button to get the correct URL.',
186-
},
211+
errors,
187212
});
188213
return;
189214
}
@@ -290,6 +315,7 @@ export const requestReview = {
290315
declinedReviewers: [],
291316
pendingReviewers: pendingReviewers,
292317
hackerRankUrl: hackerRankUrlValue,
318+
yardstickUrl: yardstickUrlValue,
293319
});
294320
},
295321
};

src/cron/__tests__/reviewProcessor.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ function mockReview(pendingReviewers: PendingReviewer[]): ActiveReview {
2323
requestorId: 'some-id',
2424
reviewersNeededCount: 2,
2525
hackerRankUrl: '',
26+
yardstickUrl: '',
2627
};
2728
}
2829

src/database/models/ActiveReview.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,10 @@ export interface ActiveReview {
2727
* The URL to the HackerRank report for this review
2828
*/
2929
hackerRankUrl: string;
30+
/**
31+
* The URL to the HackerRank Yardstick review form
32+
*/
33+
yardstickUrl: string;
3034
}
3135

3236
export interface PartialPendingReviewer {

src/database/repos/activeReviewsRepo.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ enum Column {
1717
PENDING_REVIEWERS = 'pendingReviewers',
1818
DECLINED_REVIEWERS = 'declinedReviewers',
1919
HACKERRANK_URL = 'hackerRankUrl',
20+
YARDSTICK_URL = 'yardstickUrl',
2021
}
2122

2223
function mapRowsToActiveReviews(rows: GoogleSpreadsheetRow[]): ActiveReview[] {
@@ -41,6 +42,7 @@ function mapRowToActiveReview(row: GoogleSpreadsheetRow): ActiveReview {
4142
pendingReviewers: JSON.parse(row.get(Column.PENDING_REVIEWERS)),
4243
declinedReviewers: JSON.parse(row.get(Column.DECLINED_REVIEWERS)),
4344
hackerRankUrl: row.get(Column.HACKERRANK_URL),
45+
yardstickUrl: row.get(Column.YARDSTICK_URL),
4446
};
4547
}
4648

@@ -58,6 +60,7 @@ function mapActiveReviewToRow(activeReview: ActiveReview): Record<string, any> {
5860
[Column.PENDING_REVIEWERS]: JSON.stringify(activeReview.pendingReviewers),
5961
[Column.DECLINED_REVIEWERS]: JSON.stringify(activeReview.declinedReviewers),
6062
[Column.HACKERRANK_URL]: activeReview.hackerRankUrl,
63+
[Column.YARDSTICK_URL]: activeReview.yardstickUrl,
6164
};
6265
}
6366

src/services/__tests__/QueueService.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,7 @@ describe('Queue Service', () => {
156156
declinedReviewers: [],
157157
pendingReviewers: [{ userId: 'expectedUser1', messageTimestamp: '123', expiresAt: 123 }],
158158
hackerRankUrl: '',
159+
yardstickUrl: '',
159160
},
160161
];
161162

0 commit comments

Comments
 (0)