-
Notifications
You must be signed in to change notification settings - Fork 3.1k
/
Copy pathGitUtils.ts
210 lines (190 loc) · 8.06 KB
/
GitUtils.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
import {execSync, spawn} from 'child_process';
import CONST from './CONST';
import sanitizeStringForJSONParse from './sanitizeStringForJSONParse';
import {getPreviousVersion, SEMANTIC_VERSION_LEVELS} from './versionUpdater';
import type {SemverLevel} from './versionUpdater';
type CommitType = {
commit: string;
subject: string;
authorName: string;
};
/**
* Check if a tag exists locally or in the remote.
*/
function tagExists(tag: string) {
try {
// Check if the tag exists locally
execSync(`git show-ref --tags ${tag}`, {stdio: 'ignore'});
return true; // Tag exists locally
} catch (error) {
// Tag does not exist locally, check in remote
let shouldRetry = true;
let needsRepack = false;
let doesTagExist = false;
while (shouldRetry) {
try {
if (needsRepack) {
// We have seen some scenarios where this fixes the git fetch.
// Why? Who knows... https://github.com/Expensify/App/pull/31459
execSync('git repack -d', {stdio: 'inherit'});
}
execSync(`git ls-remote --exit-code --tags origin ${tag}`, {stdio: 'ignore'});
doesTagExist = true;
shouldRetry = false;
} catch (e) {
if (!needsRepack) {
console.log('Attempting to repack and retry...');
needsRepack = true;
} else {
console.error("Repack didn't help, giving up...");
shouldRetry = false;
}
}
}
return doesTagExist;
}
}
/**
* This essentially just calls getPreviousVersion in a loop, until it finds a version for which a tag exists.
* It's useful if we manually perform a version bump, because in that case a tag may not exist for the previous version.
*
* @param tag the current tag
* @param level the Semver level to step backward by
*/
function getPreviousExistingTag(tag: string, level: SemverLevel) {
let previousVersion = getPreviousVersion(tag.replace('-staging', ''), level);
let tagExistsForPreviousVersion = false;
while (!tagExistsForPreviousVersion) {
if (tagExists(previousVersion)) {
tagExistsForPreviousVersion = true;
break;
}
if (tagExists(`${previousVersion}-staging`)) {
tagExistsForPreviousVersion = true;
previousVersion = `${previousVersion}-staging`;
break;
}
console.log(`Tag for previous version ${previousVersion} does not exist. Checking for an older version...`);
previousVersion = getPreviousVersion(previousVersion, level);
}
return previousVersion;
}
/**
* @param [shallowExcludeTag] When fetching the given tag, exclude all history reachable by the shallowExcludeTag (used to make fetch much faster)
*/
function fetchTag(tag: string, shallowExcludeTag = '') {
let shouldRetry = true;
let needsRepack = false;
while (shouldRetry) {
try {
let command = '';
if (needsRepack) {
// We have seen some scenarios where this fixes the git fetch.
// Why? Who knows... https://github.com/Expensify/App/pull/31459
command = 'git repack -d';
console.log(`Running command: ${command}`);
execSync(command);
}
command = `git fetch origin tag ${tag} --no-tags`;
// Note that this condition is only ever NOT true in the 1.0.0-0 edge case
if (shallowExcludeTag && shallowExcludeTag !== tag) {
command += ` --shallow-exclude=${shallowExcludeTag}`;
}
console.log(`Running command: ${command}`);
execSync(command);
shouldRetry = false;
} catch (e) {
console.error(e);
if (!needsRepack) {
console.log('Attempting to repack and retry...');
needsRepack = true;
} else {
console.error("Repack didn't help, giving up...");
shouldRetry = false;
}
}
}
}
/**
* Get merge logs between two tags (inclusive) as a JavaScript object.
*/
function getCommitHistoryAsJSON(fromTag: string, toTag: string): Promise<CommitType[]> {
// Fetch tags, excluding commits reachable from the previous patch version (or minor for prod) (i.e: previous checklist), so that we don't have to fetch the full history
const previousPatchVersion = getPreviousExistingTag(fromTag.replace('-staging', ''), fromTag.endsWith('-staging') ? SEMANTIC_VERSION_LEVELS.PATCH : SEMANTIC_VERSION_LEVELS.MINOR);
fetchTag(fromTag, previousPatchVersion);
fetchTag(toTag, previousPatchVersion);
console.log('Getting pull requests merged between the following tags:', fromTag, toTag);
return new Promise<string>((resolve, reject) => {
let stdout = '';
let stderr = '';
const args = ['log', '--format={"commit": "%H", "authorName": "%an", "subject": "%s"},', `${fromTag}...${toTag}`];
console.log(`Running command: git ${args.join(' ')}`);
const spawnedProcess = spawn('git', args);
spawnedProcess.on('message', console.log);
spawnedProcess.stdout.on('data', (chunk: Buffer) => {
console.log(chunk.toString());
stdout += chunk.toString();
});
spawnedProcess.stderr.on('data', (chunk: Buffer) => {
console.error(chunk.toString());
stderr += chunk.toString();
});
spawnedProcess.on('close', (code) => {
if (code !== 0) {
console.log('code: ', code);
return reject(new Error(`${stderr}`));
}
resolve(stdout);
});
spawnedProcess.on('error', (err) => reject(err));
}).then((stdout) => {
// Sanitize just the text within commit subjects as that's the only potentially un-parseable text.
const sanitizedOutput = stdout.replace(/(?<="subject": ").*?(?="})/g, (subject) => sanitizeStringForJSONParse(subject));
// Then remove newlines, format as JSON and convert to a proper JS object
const json = `[${sanitizedOutput}]`.replace(/(\r\n|\n|\r)/gm, '').replace('},]', '}]');
return JSON.parse(json) as CommitType[];
});
}
/**
* Parse merged PRs, excluding those from irrelevant branches.
*/
function getValidMergedPRs(commits: CommitType[]): number[] {
const mergedPRs = new Set<number>();
commits.forEach((commit) => {
const author = commit.authorName;
if (author === CONST.OS_BOTIFY) {
return;
}
const match = commit.subject.match(/Merge pull request #(\d+) from (?!Expensify\/.*-cherry-pick-(staging|production))/);
if (!Array.isArray(match) || match.length < 2) {
return;
}
const pr = Number.parseInt(match[1], 10);
if (mergedPRs.has(pr)) {
// If a PR shows up in the log twice, that means that the PR was deployed in the previous checklist.
// That also means that we don't want to include it in the current checklist, so we remove it now.
mergedPRs.delete(pr);
return;
}
mergedPRs.add(pr);
});
return Array.from(mergedPRs);
}
/**
* Takes in two git tags and returns a list of PR numbers of all PRs merged between those two tags
*/
async function getPullRequestsMergedBetween(fromTag: string, toTag: string) {
console.log(`Looking for commits made between ${fromTag} and ${toTag}...`);
const commitList = await getCommitHistoryAsJSON(fromTag, toTag);
console.log(`Commits made between ${fromTag} and ${toTag}:`, commitList);
// Find which commit messages correspond to merged PR's
const pullRequestNumbers = getValidMergedPRs(commitList).sort((a, b) => a - b);
console.log(`List of pull requests merged between ${fromTag} and ${toTag}`, pullRequestNumbers);
return pullRequestNumbers;
}
export default {
getPreviousExistingTag,
getValidMergedPRs,
getPullRequestsMergedBetween,
};
export type {CommitType};