-
Notifications
You must be signed in to change notification settings - Fork 2
Expand file tree
/
Copy pathlabel-pr.ts
More file actions
169 lines (145 loc) · 5.85 KB
/
Copy pathlabel-pr.ts
File metadata and controls
169 lines (145 loc) · 5.85 KB
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
/**
* Applies size and semantic labels to a pull request, from the files it touches and its title.
*
* Loaded by the "Apply size and semantic labels" step of `pull-request.yml`, which hands it the
* authenticated client and the event context that `actions/github-script` puts in scope.
*/
/** A file in the pull request, as `pulls.listFiles` returns it. */
interface PullRequestFile {
readonly filename: string;
readonly additions: number;
readonly deletions: number;
}
interface PullRequestRef {
readonly owner: string;
readonly repo: string;
readonly pull_number: number;
readonly per_page: number;
}
interface IssueRef {
readonly owner: string;
readonly repo: string;
readonly issue_number: number;
}
type ListFiles = (params: PullRequestRef) => Promise<{ data: PullRequestFile[] }>;
/** The slice of the Octokit client this script uses. */
interface GitHubApi {
readonly paginate: (endpoint: ListFiles, params: PullRequestRef) => Promise<PullRequestFile[]>;
readonly rest: {
readonly pulls: { readonly listFiles: ListFiles };
readonly issues: {
readonly listLabelsOnIssue: (
params: IssueRef,
) => Promise<{ data: { name: string }[] }>;
readonly addLabels: (params: IssueRef & { labels: string[] }) => Promise<unknown>;
readonly removeLabel: (params: IssueRef & { name: string }) => Promise<unknown>;
};
};
}
/** The slice of `actions/github-script`'s `context` this script uses. */
interface GitHubContext {
readonly repo: { readonly owner: string; readonly repo: string };
readonly issue: { readonly number: number };
readonly payload: { readonly pull_request?: { readonly title?: string } };
}
/** Generated or vendored files, which say nothing about how much work a pull request is to review. */
const GENERATED_PATTERNS = [
// Generated code patterns
/\.gen\.(ts|js|tsx|jsx)$/, // *.gen.ts files
/\.generated\.(ts|js|tsx|jsx)$/, // *.generated.ts files
// Webapp generated (entire api directory is generated by @hey-api/openapi-ts)
/^webapp\/src\/api\//, // All files in webapp/src/api/
/^webapp\/src\/routeTree\.gen\.ts$/, // TanStack Router generated
// Server generated
/^server\/openapi\.yaml$/, // Generated OpenAPI spec
// Docs generated
/^docs\/contributor\/erd\/.*\.mmd$/, // Generated Mermaid ERD diagrams
/pnpm-lock\.yaml$/,
/\.lock$/,
];
/** Conventional Commit type, and the label it earns. Several types share one label on purpose. */
const TYPE_LABELS: readonly (readonly [RegExp, string])[] = [
[/^feat(\(.*\))?!?:/, "feature"],
[/^fix(\(.*\))?!?:/, "bug"],
[/^docs(\(.*\))?!?:/, "documentation"],
[/^chore(\(.*\))?!?:/, "maintenance"],
[/^refactor(\(.*\))?!?:/, "refactor"],
[/^perf(\(.*\))?!?:/, "performance"],
[/^test(\(.*\))?!?:/, "test"],
[/^build(\(.*\))?!?:/, "maintenance"],
[/^style(\(.*\))?!?:/, "maintenance"],
[/^revert(\(.*\))?!?:/, "revert"],
[/^ci(\(.*\))?!?:/, "ci"],
];
/** `!` immediately before the `:`; `[^)]*` keeps it from matching inside the scope parentheses. */
const BREAKING = /^\w+(\([^)]*\))?!:/;
const sizeLabelFor = (changedLines: number): string => {
if (changedLines > 1000) return "size:XXL";
if (changedLines > 499) return "size:XL";
if (changedLines > 99) return "size:L";
if (changedLines > 29) return "size:M";
if (changedLines > 9) return "size:S";
return "size:XS";
};
const changedLinesIn = (files: readonly PullRequestFile[]): number =>
files.reduce((sum, file) => sum + file.additions + file.deletions, 0);
export default async function labelPullRequest({
github,
context,
}: {
github: GitHubApi;
context: GitHubContext;
}): Promise<void> {
const issue = {
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: context.issue.number,
};
console.log("Calculating PR size via listFiles pagination...");
const files = await github.paginate(github.rest.pulls.listFiles, {
owner: context.repo.owner,
repo: context.repo.repo,
pull_number: context.issue.number,
per_page: 100,
});
const isGenerated = (filename: string): boolean =>
GENERATED_PATTERNS.some((pattern) => pattern.test(filename));
const nonGeneratedFiles = files.filter((file) => !isGenerated(file.filename));
const generatedFiles = files.filter((file) => isGenerated(file.filename));
const changedLines = changedLinesIn(nonGeneratedFiles);
console.log(`Changed lines (excluding generated): ${changedLines}`);
console.log(
`Generated files excluded: ${generatedFiles.length} files, ${changedLinesIn(generatedFiles)} lines`,
);
if (generatedFiles.length > 0) {
console.log(`Excluded files: ${generatedFiles.map((file) => file.filename).join(", ")}`);
}
const sizeLabel = sizeLabelFor(changedLines);
const title = context.payload.pull_request?.title ?? "";
const typeLabels = TYPE_LABELS.filter(([pattern]) => pattern.test(title)).map(([, label]) => label);
if (BREAKING.test(title) || title.includes("BREAKING CHANGE")) typeLabels.push("breaking");
console.log(`Detected type labels from title "${title}":`, typeLabels);
console.log("Fetching existing labels...");
const labelsResponse = await github.rest.issues.listLabelsOnIssue(issue);
const existingLabels = labelsResponse.data.map((label) => label.name);
// 1. Handle Size Labels
const sizeToRemove = existingLabels.filter(
(name) => name.startsWith("size:") && name !== sizeLabel,
);
if (sizeToRemove.length > 0) {
console.log(`Removing old size labels: ${sizeToRemove.join(", ")}`);
for (const name of sizeToRemove) {
await github.rest.issues.removeLabel({ ...issue, name });
}
}
if (!existingLabels.includes(sizeLabel)) {
console.log(`Adding size label: ${sizeLabel}`);
await github.rest.issues.addLabels({ ...issue, labels: [sizeLabel] });
}
// 2. Handle Semantic Labels
const labelsToAdd = typeLabels.filter((label) => !existingLabels.includes(label));
if (labelsToAdd.length > 0) {
console.log(`Adding semantic labels: ${labelsToAdd.join(", ")}`);
await github.rest.issues.addLabels({ ...issue, labels: labelsToAdd });
}
}