Skip to content

Commit f6a332c

Browse files
authored
feat(github): Autofill repository name when setting up GitHub builds (#5347)
* feat: implement autofill functionality for RepoSelector component - Automatically fills repository input when organization is selected and matching repository with snap name exists - Runs automatic validation after autofill - Allows user override of autofilled values - Optimized performance with proper memoization - Added comprehensive test coverage (11 tests) - Maintains backward compatibility with manual selection Fixes #3319 * fix: remove unnecessary useCallback hooks and fix input clearing race condition * fix: extract getRepoNameWithOwner utility and remove excessive useCallback usage * test: refactor PR comments * fix: removed setRepoLoading to prevent potential race condition and unlimited loading spinner * retrigger CI
1 parent 250df1c commit f6a332c

2 files changed

Lines changed: 547 additions & 57 deletions

File tree

static/js/publisher/pages/Builds/RepoSelector.tsx

Lines changed: 136 additions & 57 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,11 @@
1-
import { ChangeEvent, Dispatch, SetStateAction, useState } from "react";
1+
import {
2+
ChangeEvent,
3+
Dispatch,
4+
SetStateAction,
5+
useState,
6+
useEffect,
7+
useRef,
8+
} from "react";
29
import { useParams } from "react-router-dom";
310
import { useSetAtom } from "jotai";
411
import {
@@ -21,6 +28,14 @@ type Props = {
2128
setAutoTriggerBuild: Dispatch<SetStateAction<boolean>>;
2229
};
2330

31+
// Utility function for formatting repository names with owner
32+
const getRepoNameWithOwner = (
33+
selectedOrg: string | null,
34+
repo: Repo,
35+
): string => {
36+
return selectedOrg ? `${selectedOrg}/${repo.name}` : repo.nameWithOwner;
37+
};
38+
2439
function generateYamlTemplateUrl(
2540
org: string | null,
2641
repo: string | undefined,
@@ -67,55 +82,79 @@ function RepoSelector({ githubData, setAutoTriggerBuild }: Props) {
6782
const [reposLoading, setReposLoading] = useState<boolean>(false);
6883
const [validRepo, setValidRepo] = useState<boolean | null>(null);
6984
const [validationError, setValidationError] = useState<boolean>(false);
70-
const [missingYaml, setMissingYaml] = useState<boolean>(false);
71-
const [defaultBranch, setDefaultBranch] = useState<string | null>(null);
85+
const [repoInputValue, setRepoInputValue] = useState<string>("");
86+
const [repoFetchError, setRepoFetchError] = useState<string>("");
87+
const [repoConnectError, setRepoConnectError] = useState<string>("");
7288

73-
const getRepoNameWithOwner = (repo: Repo) =>
74-
selectedOrg ? `${selectedOrg}/${repo.name}` : repo.nameWithOwner;
89+
const [defaultBranch, setDefaultBranch] = useState<string | null>(null);
7590

76-
const validateRepo = async (repo: Repo | undefined) => {
77-
setMissingYaml(false);
91+
// Track autofill attempts to avoid unnecessary re-runs
92+
const autofillAttemptedRef = useRef<string | null>(null);
7893

94+
const validateRepoInternal = async (repo: Repo | undefined) => {
7995
if (!repo) {
8096
return;
8197
}
8298

8399
setValidating(true);
84100

85-
const repoName = getRepoNameWithOwner(repo);
101+
const repoName = getRepoNameWithOwner(selectedOrg, repo);
86102

87-
const response = await fetch(
88-
`/api/${snapId}/builds/validate-repo?repo=${repoName}`,
89-
);
103+
try {
104+
const response = await fetch(
105+
`/api/${snapId}/builds/validate-repo?repo=${repoName}`,
106+
);
90107

91-
if (!response.ok) {
92-
setValidationError(true);
93-
throw new Error("Not a valid repo");
94-
}
108+
if (!response.ok) {
109+
setValidationError(true);
110+
throw new Error("Not a valid repo");
111+
}
95112

96-
const responseData = await response.json();
113+
const responseData = await response.json();
97114

98-
if (responseData.success) {
99-
setValidRepo(true);
100-
setValidationMessage("");
101-
setValidationError(false);
102-
setMissingYaml(false);
103-
} else {
104-
const error = responseData.error;
115+
if (responseData.success) {
116+
setValidRepo(true);
117+
setValidationMessage("");
118+
setValidationError(false);
119+
} else {
120+
const error = responseData.error;
105121

106-
if (error.type === "MISSING_YAML_FILE") {
107-
setMissingYaml(true);
108-
setDefaultBranch(responseData.data.default_branch);
109-
}
122+
if (error.type === "MISSING_YAML_FILE") {
123+
setDefaultBranch(responseData.data.default_branch);
124+
}
110125

111-
setValidationMessage(responseData.error.message);
126+
setValidationMessage(responseData.error.message);
127+
setValidationError(true);
128+
setValidRepo(null);
129+
}
130+
} catch {
112131
setValidationError(true);
113-
setValidRepo(null);
132+
} finally {
133+
setValidating(false);
114134
}
115-
116-
setValidating(false);
117135
};
118136

137+
// Autofill effect with proper dependencies
138+
useEffect(() => {
139+
const currentOrgKey = `${selectedOrg || "user"}-${snapId}`;
140+
141+
if (
142+
repos.length > 0 &&
143+
snapId &&
144+
!repoInputValue &&
145+
autofillAttemptedRef.current !== currentOrgKey
146+
) {
147+
const matchingRepo = repos.find((repo: Repo) => repo.name === snapId);
148+
if (matchingRepo) {
149+
setRepoInputValue(matchingRepo.name);
150+
setSelectedRepo(matchingRepo);
151+
validateRepoInternal(matchingRepo);
152+
}
153+
// Mark autofill as attempted for this org/snap combination
154+
autofillAttemptedRef.current = currentOrgKey;
155+
}
156+
}, [repos, snapId, repoInputValue, selectedOrg]);
157+
119158
const getRepos = async (org?: string) => {
120159
setReposLoading(true);
121160

@@ -125,17 +164,21 @@ function RepoSelector({ githubData, setAutoTriggerBuild }: Props) {
125164
apiUrl += `?org=${org}`;
126165
}
127166

128-
const response = await fetch(apiUrl);
129-
130-
if (!response.ok) {
131-
throw Error("Unable to fetch repos");
132-
}
133-
134-
const responseData = await response.json();
167+
try {
168+
const response = await fetch(apiUrl);
135169

136-
setReposLoading(false);
170+
if (!response.ok) {
171+
throw Error("Unable to fetch repos");
172+
}
137173

138-
setRepos(responseData);
174+
const responseData = await response.json();
175+
setRepos(responseData);
176+
} catch (_error) {
177+
setRepoFetchError("Failed to fetch repositories. Please try again.");
178+
setRepos([]);
179+
} finally {
180+
setReposLoading(false);
181+
}
139182
};
140183

141184
const getOrgs = (): { label: string; value: string }[] => {
@@ -166,9 +209,16 @@ function RepoSelector({ githubData, setAutoTriggerBuild }: Props) {
166209
const handleOrganizationChange = (e: ChangeEvent) => {
167210
const target = e.target as HTMLInputElement;
168211

169-
if (target.value) {
170-
setReposLoading(true);
212+
setRepoInputValue("");
213+
setSelectedRepo(undefined);
214+
setValidRepo(null);
215+
setValidationError(false);
216+
setRepoFetchError("");
217+
setRepoConnectError("");
218+
setRepos([]);
219+
autofillAttemptedRef.current = null;
171220

221+
if (target.value) {
172222
const org = target.value;
173223

174224
if (org === githubData.github_user.login) {
@@ -187,39 +237,53 @@ function RepoSelector({ githubData, setAutoTriggerBuild }: Props) {
187237
const target = e.target as HTMLInputElement;
188238
const searchTerm = target.value;
189239

240+
setRepoInputValue(searchTerm);
241+
setRepoFetchError("");
242+
setRepoConnectError("");
243+
190244
if (!searchTerm) {
191245
setValidationError(false);
192-
setValidRepo(false);
246+
setValidRepo(null);
247+
setSelectedRepo(undefined);
248+
return;
193249
}
194250

195251
const selectedRepo = repos.find((repo: Repo) => repo.name === searchTerm);
196252

197253
setSelectedRepo(selectedRepo);
198254

199255
if (selectedRepo) {
200-
validateRepo(selectedRepo);
256+
validateRepoInternal(selectedRepo);
201257
}
202258
};
203259

204260
const connectRepo = async () => {
205261
setBuilding(true);
262+
setRepoConnectError("");
206263
const formData = new FormData();
207264
formData.set("csrf_token", window.CSRF_TOKEN);
208265
if (selectedRepo) {
209-
const repoName = getRepoNameWithOwner(selectedRepo);
266+
const repoName = getRepoNameWithOwner(selectedOrg, selectedRepo);
210267
formData.set("github_repository", repoName);
211268
}
212269

213-
const response = await fetch(`/api/${snapId}/builds`, {
214-
method: "POST",
215-
body: formData,
216-
});
270+
try {
271+
const response = await fetch(`/api/${snapId}/builds`, {
272+
method: "POST",
273+
body: formData,
274+
});
217275

218-
if (response) {
219-
setAutoTriggerBuild(true);
276+
if (response.ok) {
277+
setAutoTriggerBuild(true);
278+
setRepoConnected(true);
279+
} else {
280+
setRepoConnectError("Failed to connect repository. Please try again.");
281+
}
282+
} catch (_error) {
283+
setRepoConnectError("Failed to connect repository. Please try again.");
284+
} finally {
285+
setBuilding(false);
220286
}
221-
222-
setRepoConnected(true);
223287
};
224288

225289
return (
@@ -257,6 +321,7 @@ function RepoSelector({ githubData, setAutoTriggerBuild }: Props) {
257321
placeholder="Search your repos"
258322
disabled={selectedOrg === null || validating}
259323
className="p-form-validation__input"
324+
value={repoInputValue}
260325
onChange={handleRepoChange}
261326
/>
262327
{reposLoading ||
@@ -270,6 +335,11 @@ function RepoSelector({ githubData, setAutoTriggerBuild }: Props) {
270335
<option value={repo.name} key={repo.name} />
271336
))}
272337
</datalist>
338+
{repoFetchError && (
339+
<p className="p-form-validation__message" role="alert">
340+
{repoFetchError}
341+
</p>
342+
)}
273343
</Col>
274344
<Col size={2}>
275345
<div style={{ display: "flex", alignItems: "end", height: "100%" }}>
@@ -289,7 +359,7 @@ function RepoSelector({ githubData, setAutoTriggerBuild }: Props) {
289359
className="p-tooltip--btm-center"
290360
aria-describedby="recheck-tooltip"
291361
onClick={() => {
292-
validateRepo(selectedRepo);
362+
validateRepoInternal(selectedRepo);
293363
}}
294364
>
295365
<i className="p-icon--restart"></i>
@@ -298,14 +368,14 @@ function RepoSelector({ githubData, setAutoTriggerBuild }: Props) {
298368
role="tooltip"
299369
id="recheck-tooltip"
300370
>
301-
Re-check
371+
Check again
302372
</span>
303373
</Button>
304374
)}
305375
</div>
306376
</Col>
307377
</Row>
308-
{missingYaml && (
378+
{validationError && (
309379
<div className="u-fixed-width">
310380
<p>{validationMessage}</p>
311381
<p>
@@ -317,7 +387,7 @@ function RepoSelector({ githubData, setAutoTriggerBuild }: Props) {
317387
selectedOrg,
318388
selectedRepo?.name,
319389
defaultBranch,
320-
snapId,
390+
snapId || "",
321391
)}
322392
>
323393
get started with a template
@@ -333,6 +403,15 @@ function RepoSelector({ githubData, setAutoTriggerBuild }: Props) {
333403
</p>
334404
</div>
335405
)}
406+
{repoConnectError && (
407+
<Row>
408+
<Col size={12}>
409+
<p className="p-form-validation__message" role="alert">
410+
{repoConnectError}
411+
</p>
412+
</Col>
413+
</Row>
414+
)}
336415
</Strip>
337416
);
338417
}

0 commit comments

Comments
 (0)