1+ # Copyright 2025 Google LLC
2+ #
3+ # Licensed under the Apache License, Version 2.0 (the "License");
4+ # you may not use this file except in compliance with the License.
5+ # You may obtain a copy of the License at
6+ #
7+ # http://www.apache.org/licenses/LICENSE-2.0
8+ #
9+ # Unless required by applicable law or agreed to in writing, software
10+ # distributed under the License is distributed on an "AS IS" BASIS,
11+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+ # See the License for the specific language governing permissions and
13+ # limitations under the License.
14+
15+ name : Cloud Build Failure Reporter
16+
17+ on :
18+ workflow_call :
19+ inputs :
20+ trigger_names :
21+ required : true
22+ type : string
23+ workflow_dispatch :
24+ inputs :
25+ trigger_names :
26+ description : ' Cloud Build trigger names separated by comma.'
27+ required : true
28+ default : ' '
29+
30+ jobs :
31+ report :
32+
33+ permissions :
34+ issues : ' write'
35+ checks : ' read'
36+ contents : ' read'
37+
38+ runs-on : ' ubuntu-latest'
39+
40+ steps :
41+ - uses : ' actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea' # v7
42+ with :
43+ script : |-
44+ // parse test names
45+ const testNameSubstring = '${{ inputs.trigger_names }}';
46+ const testNameFound = new Map(); //keeps track of whether each test is found
47+ testNameSubstring.split(',').forEach(testName => {
48+ testNameFound.set(testName, false);
49+ });
50+
51+ // label for all issues opened by reporter
52+ const periodicLabel = 'periodic-failure';
53+
54+ // check if any reporter opened any issues previously
55+ const prevIssues = await github.paginate(github.rest.issues.listForRepo, {
56+ ...context.repo,
57+ state: 'open',
58+ creator: 'github-actions[bot]',
59+ labels: [periodicLabel]
60+ });
61+
62+ // createOrCommentIssue creates a new issue or comments on an existing issue.
63+ const createOrCommentIssue = async function (title, txt) {
64+ if (prevIssues.length < 1) {
65+ console.log('no previous issues found, creating one');
66+ await github.rest.issues.create({
67+ ...context.repo,
68+ title: title,
69+ body: txt,
70+ labels: [periodicLabel]
71+ });
72+ return;
73+ }
74+ // only comment on issue related to the current test
75+ for (const prevIssue of prevIssues) {
76+ if (prevIssue.title.includes(title)){
77+ console.log(
78+ `found previous issue ${prevIssue.html_url}, adding comment`
79+ );
80+
81+ await github.rest.issues.createComment({
82+ ...context.repo,
83+ issue_number: prevIssue.number,
84+ body: txt
85+ });
86+ return;
87+ }
88+ }
89+ };
90+
91+ // updateIssues comments on any existing issues. No-op if no issue exists.
92+ const updateIssues = async function (checkName, txt) {
93+ if (prevIssues.length < 1) {
94+ console.log('no previous issues found.');
95+ return;
96+ }
97+ // only comment on issue related to the current test
98+ for (const prevIssue of prevIssues) {
99+ if (prevIssue.title.includes(checkName)){
100+ console.log(`found previous issue ${prevIssue.html_url}, adding comment`);
101+ await github.rest.issues.createComment({
102+ ...context.repo,
103+ issue_number: prevIssue.number,
104+ body: txt
105+ });
106+ }
107+ }
108+ };
109+
110+ // Find status of check runs.
111+ // We will find check runs for each commit and then filter for the periodic.
112+ // Checks API only allows for ref and if we use main there could be edge cases where
113+ // the check run happened on a SHA that is different from head.
114+ const commits = await github.paginate(github.rest.repos.listCommits, {
115+ ...context.repo
116+ });
117+
118+ const relevantChecks = new Map();
119+ for (const commit of commits) {
120+ console.log(
121+ `checking runs at ${commit.html_url}: ${commit.commit.message}`
122+ );
123+ const checks = await github.rest.checks.listForRef({
124+ ...context.repo,
125+ ref: commit.sha
126+ });
127+
128+ // Iterate through each check and find matching names
129+ for (const check of checks.data.check_runs) {
130+ console.log(`Handling test name ${check.name}`);
131+ for (const testName of testNameFound.keys()) {
132+ if (testNameFound.get(testName) === true){
133+ //skip if a check is already found for this name
134+ continue;
135+ }
136+ if (check.name.includes(testName)) {
137+ relevantChecks.set(check, commit);
138+ testNameFound.set(testName, true);
139+ }
140+ }
141+ }
142+ // Break out of the loop early if all tests are found
143+ const allTestsFound = Array.from(testNameFound.values()).every(value => value === true);
144+ if (allTestsFound){
145+ break;
146+ }
147+ }
148+
149+ // Handle each relevant check
150+ relevantChecks.forEach((commit, check) => {
151+ if (
152+ check.status === 'completed' &&
153+ check.conclusion === 'success'
154+ ) {
155+ updateIssues(
156+ check.name,
157+ `[Tests are passing](${check.html_url}) for commit [${commit.sha}](${commit.html_url}).`
158+ );
159+ } else if (check.status === 'in_progress') {
160+ console.log(
161+ `Check is pending ${check.html_url} for ${commit.html_url}. Retry again later.`
162+ );
163+ } else {
164+ createOrCommentIssue(
165+ `Cloud Build Failure Reporter: ${check.name} failed`,
166+ `Cloud Build Failure Reporter found test failure for [**${check.name}** ](${check.html_url}) at [${commit.sha}](${commit.html_url}). Please fix the error and then close the issue after the **${check.name}** test passes.`
167+ );
168+ }
169+ });
170+
171+ // no periodic checks found across all commits, report it
172+ const noTestFound = Array.from(testNameFound.values()).every(value => value === false);
173+ if (noTestFound){
174+ createOrCommentIssue(
175+ 'Missing periodic tests: ${{ inputs.trigger_names }}',
176+ `No periodic test is found for triggers: ${{ inputs.trigger_names }}. Last checked from ${
177+ commits[0].html_url
178+ } to ${commits[commits.length - 1].html_url}.`
179+ );
180+ }
181+
0 commit comments