-
Notifications
You must be signed in to change notification settings - Fork 2
160 lines (144 loc) Β· 6.23 KB
/
Copy pathpr-label.yml
File metadata and controls
160 lines (144 loc) Β· 6.23 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
name: PR label
# The work-type label comes from the title, the size label comes from the current
# diff, and good first issue comes from a linked closing issue. Each source is
# observable, so none of these labels needs a hand-maintained estimate.
#
# pull_request_target rather than pull_request, because a pull_request run from
# a fork gets a read-only token and could not label at all, which would fail
# every outside contribution. Nothing here checks out or runs the pull request's
# code. It reads the title out of the event payload, so the elevated token never
# meets anything the author controls beyond that string.
on:
pull_request_target:
types: [opened, edited, reopened, synchronize]
permissions:
contents: read
issues: read
pull-requests: write
jobs:
label:
name: label from title
runs-on: ubuntu-latest
steps:
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
// The commit types in CONTRIBUTING.md, mapped to the work-type
// labels in docs/labels.md. The two lists are named differently,
// since the labels predate the type table, so the mapping is
// spelled out rather than assumed to be identity.
const LABELS = {
feat: 'enhancement',
fix: 'bug',
docs: 'documentation',
refactor: 'refactor',
test: 'test',
chore: 'chore',
};
const SIZE_LABELS = [
{ name: 'π xs', max: 9 },
{ name: 'π s', max: 49 },
{ name: 'π m', max: 199 },
{ name: 'π l', max: 999 },
{ name: 'π xl', max: Infinity },
];
// Fetch the pull request instead of trusting the event payload so a
// synchronize event sees the latest additions and deletions.
const { data: pr } = await github.rest.pulls.get({
...context.repo,
pull_number: context.payload.pull_request.number,
});
// `!` marks a breaking change in Conventional Commits. Scopes are
// not used here, so `feat(core):` is rejected along with anything
// else that does not match the documented `<type>: <description>`.
const match = /^([a-z]+)(!)?: .+/.exec(pr.title);
const type = match?.[1];
const wanted = type ? LABELS[type] : undefined;
if (!wanted) {
core.setFailed(
`The title "${pr.title}" does not start with a known commit type. ` +
`Use "<type>: <description>" with one of: ${Object.keys(LABELS).join(', ')}. ` +
`See the Commits section of CONTRIBUTING.md.`,
);
return;
}
// Exactly one work-type label, so a corrected title does not leave
// the old one behind.
const workType = new Set(Object.values(LABELS));
const current = pr.labels.map((label) => label.name);
const stale = current.filter((name) => workType.has(name) && name !== wanted);
for (const name of stale) {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: pr.number,
name,
});
}
if (!current.includes(wanted)) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: pr.number,
labels: [wanted],
});
}
// Size is PR-only and always comes from additions + deletions. Keep
// exactly one so a synchronize event replaces a stale size.
const changedLines = pr.additions + pr.deletions;
const wantedSize = SIZE_LABELS.find(({ max }) => changedLines <= max).name;
const sizeNames = new Set(SIZE_LABELS.map(({ name }) => name));
const staleSizes = current.filter(
(name) => sizeNames.has(name) && name !== wantedSize,
);
for (const name of staleSizes) {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: pr.number,
name,
});
}
if (!current.includes(wantedSize)) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: pr.number,
labels: [wantedSize],
});
}
// GitHub resolves closingIssuesReferences from closing keywords and
// linked development work. Copy good first issue only when at least
// one of those issues carries it.
const linked = await github.graphql(
`query($owner: String!, $repo: String!, $number: Int!) {
repository(owner: $owner, name: $repo) {
pullRequest(number: $number) {
closingIssuesReferences(first: 50) {
nodes {
labels(first: 100) { nodes { name } }
}
}
}
}
}`,
{ ...context.repo, number: pr.number },
);
const closingIssues =
linked.repository.pullRequest.closingIssuesReferences.nodes;
const shouldBeGoodFirst = closingIssues.some((issue) =>
issue.labels.nodes.some((label) => label.name === 'good first issue'),
);
const isGoodFirst = current.includes('good first issue');
if (shouldBeGoodFirst && !isGoodFirst) {
await github.rest.issues.addLabels({
...context.repo,
issue_number: pr.number,
labels: ['good first issue'],
});
} else if (!shouldBeGoodFirst && isGoodFirst) {
await github.rest.issues.removeLabel({
...context.repo,
issue_number: pr.number,
name: 'good first issue',
});
}
core.info(
`${pr.title} -> ${wanted}, ${wantedSize}, good first issue: ${shouldBeGoodFirst}`,
);