Skip to content

feat(x2a): RepoAuthentication renders summary of required auth tokens#2596

Merged
eloycoto merged 2 commits intoredhat-developer:mainfrom
mareklibra:FLPATH-3464.RepoAuthenticationSummary
Mar 26, 2026
Merged

feat(x2a): RepoAuthentication renders summary of required auth tokens#2596
eloycoto merged 2 commits intoredhat-developer:mainfrom
mareklibra:FLPATH-3464.RepoAuthenticationSummary

Conversation

@mareklibra
Copy link
Copy Markdown
Member

@mareklibra mareklibra commented Mar 24, 2026

Fixes: FLPATH-3464

Within the CSV import flow, the RepoAuthentication widget shows summary of the required SCM provider tokens.


Screencast.From.2026-03-24.12-52-02.mp4

@rhdh-gh-app
Copy link
Copy Markdown

rhdh-gh-app bot commented Mar 24, 2026

Changed Packages

Package Name Package Path Changeset Bump Current Version
@red-hat-developer-hub/backstage-plugin-x2a workspaces/x2a/plugins/x2a patch v1.0.2

@rhdh-qodo-merge
Copy link
Copy Markdown

Review Summary by Qodo

RepoAuthentication displays provider summary with per-provider retry

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Renders provider authentication summary table with status indicators
• Implements per-provider retry mechanism for failed authentications
• Tracks individual provider statuses (pending, authenticated, error)
• Displays OAuth scopes and access levels (Read/Write vs Read-only)
• Adds comprehensive test coverage for error handling and retry flows
Diagram
flowchart LR
  CSV["CSV Content"] -->|parse| Providers["Distinct Providers<br/>Target + Source"]
  Providers -->|auto-auth| AuthFlow["Initial Authentication<br/>All Providers"]
  AuthFlow -->|success| Table["Provider Table<br/>Status + Scope"]
  AuthFlow -->|failure| ErrorState["Error State<br/>Per-Provider"]
  ErrorState -->|retry click| RetryFlow["Per-Provider Retry"]
  RetryFlow -->|success| Table
  Table -->|all authenticated| Complete["Set Secrets<br/>onChange"]
Loading

Grey Divider

File Changes

1. workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx ✨ Enhancement +265/-84

Add provider table with per-provider retry mechanism

• Refactored authentication flow to track per-provider status (pending, authenticated, error)
• Added provider summary table displaying provider name, access level, OAuth scope, and status
• Implemented per-provider retry button that only appears for error rows
• Replaced global error state with per-provider error tracking in Map
• Added StatusIcon component to visualize authentication status using Backstage components
• Extracted provider parsing logic into useMemo for better performance
• Separated initial auto-auth from manual retry flows using initialAuthFailed flag

workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx


2. workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.test.tsx 🧪 Tests +619/-57

Expand tests for provider table and retry flows

• Added ThemeProvider wrapper with status color palette for tests
• Added comprehensive tests for per-provider retry functionality
• Added tests for mixed provider scenarios (GitHub + GitLab)
• Added tests for provider table rendering with correct access levels and scopes
• Added tests for provider status tracking (authenticated, error, pending)
• Added tests for error message display per provider row
• Added tests for concurrent retry scenarios
• Refactored test setup to reduce code duplication with baseProps

workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.test.tsx


3. workspaces/x2a/.changeset/long-baboons-dig.md 📝 Documentation +5/-0

Add changeset for RepoAuthentication summary feature

• Added changeset entry documenting the patch version bump
• Describes addition of summary to RepoAuthentication widget

workspaces/x2a/.changeset/long-baboons-dig.md


View more (1)
4. workspaces/x2a/templates/conversion-project-template.yaml ⚙️ Configuration changes +2/-0

Make repoAuthentication required for CSV import

• Made repoAuthentication field required in CSV import conditional schema
• Ensures repoAuthentication is mandatory when inputMethod is set to csv

workspaces/x2a/templates/conversion-project-template.yaml


Grey Divider

Qodo Logo

@rhdh-qodo-merge
Copy link
Copy Markdown

rhdh-qodo-merge bot commented Mar 24, 2026

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0) 📐 Spec deviations (0)

Grey Divider


Action required

1. Pending rows after failure 🐞 Bug ⛯ Reliability
Description
When any provider authentication fails, the component sets initialAuthFailed=true, which is also a
dependency of the auto-auth effect; this triggers effect cleanup (cancelled=true) while other
provider auth calls may still be in-flight, preventing those providers from ever setting
status/error. Those providers remain in 'pending' and do not render a Retry button, which can block
completion indefinitely.
Code

workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[R194-268]

  useEffect(() => {
-    if (!csvContent || suppressDialog || isDone) {
+    if (!csvContent || initialAuthFailed || isDone || parseError) {
      return undefined;
    }

-    setError(undefined);
+    const targets = distinctTargetProviders;
+    const sources = distinctSourceProviders;
+    const allDistinctProviders = [...targets, ...sources];

-    let projectsToCreate;
-    try {
-      projectsToCreate = parseCsvContent(csvContent);
-    } catch (e) {
-      setError(e instanceof Error ? e.message : 'Unknown error');
+    if (allDistinctProviders.length === 0) {
      return undefined;
    }

-    const allTargetProviders: ScmProvider[] = projectsToCreate.map(project =>
-      resolveScmProvider(project.targetRepoUrl, hostProviderMap),
-    );
-    const allSourceProviders: ScmProvider[] = projectsToCreate.map(project =>
-      resolveScmProvider(project.sourceRepoUrl, hostProviderMap),
-    );
-    const distinctTargetProviders = allTargetProviders.filter(
-      (p, i, arr) => arr.findIndex(q => q.name === p.name) === i,
-    );
-    const distinctSourceProviders = allSourceProviders.filter(
-      (p, i, arr) =>
-        arr.findIndex(q => q.name === p.name) === i &&
-        !distinctTargetProviders.some(t => t.name === p.name),
-    );
-    const allDistinctProviders = [
-      ...distinctTargetProviders,
-      ...distinctSourceProviders,
-    ];
-
    let cancelled = false;
    const authCsvSnapshot = csvContent;

    const doAuthAsync = async () => {
-      const providerTokens = new Map<string, string>();
-
      const authenticateProvider = async (
        provider: ScmProvider,
        readOnly: boolean,
      ) => {
        try {
-          const tokens = await repoAuthentication.authenticate([
+          const tokens = await repoAuthRef.current.authenticate([
            provider.getAuthTokenDescriptor(readOnly),
          ]);
          if (cancelled) {
            return;
          }
-          providerTokens.set(
+          providerTokensRef.current.set(
            `${SCAFFOLDER_SECRET_PREFIX}${provider.name}`,
            tokens[0].token,
          );
+          setProviderStatuses(prev =>
+            new Map(prev).set(provider.name, 'authenticated'),
+          );
        } catch (e) {
          if (cancelled) {
            return;
          }
-          setError(e instanceof Error ? e.message : 'Unknown error');
-          setSuppressDialog(true);
+          setProviderStatuses(prev =>
+            new Map(prev).set(provider.name, 'error'),
+          );
+          setProviderErrors(prev =>
+            new Map(prev).set(
+              provider.name,
+              e instanceof Error ? e.message : 'Unknown error',
+            ),
+          );
+          setInitialAuthFailed(true);
        }
      };

      await Promise.all([
-        ...distinctTargetProviders.map(p => authenticateProvider(p, false)),
-        ...distinctSourceProviders.map(p => authenticateProvider(p, true)),
+        ...targets.map(p => authenticateProvider(p, false)),
+        ...sources.map(p => authenticateProvider(p, true)),
      ]);

-      if (
-        cancelled ||
-        authCsvSnapshot !== prevCsvRef.current ||
-        providerTokens.size !== allDistinctProviders.length
-      ) {
-        if (
-          !cancelled &&
-          authCsvSnapshot === prevCsvRef.current &&
-          providerTokens.size !== allDistinctProviders.length
-        ) {
-          onChange(undefined);
-        }
+      if (cancelled || authCsvSnapshot !== prevCsvRef.current) {
        return;
      }

-      onChange('authenticated');
-      setDone(true);
-      setSecrets({
-        ...secretsRef.current,
-        ...Object.fromEntries(providerTokens),
-      });
+      if (providerTokensRef.current.size !== allDistinctProviders.length) {
+        onChangeRef.current(undefined);
+      }
    };

    doAuthAsync();

    return () => {
      cancelled = true;
    };
-  }, [
-    csvContent,
-    hostProviderMap,
-    repoAuthentication,
-    setSecrets,
-    suppressDialog,
-    isDone,
-    onChange,
-  ]);
+    // csvContent change is the meaningful trigger for re-authentication.
+    // distinctTarget/SourceProviders are derived from it and captured above.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [csvContent, initialAuthFailed, isDone, parseError]);
Evidence
The auto-auth effect depends on initialAuthFailed and sets it to true on the first provider failure;
React will run the previous effect’s cleanup when dependencies change, setting cancelled=true. Any
other provider auth promise that resolves after that will hit if (cancelled) return; and therefore
never updates providerStatuses/providerErrors, leaving that row at the default 'pending' state. The
UI only shows the Retry button for rows with status==='error', so a pending row has no recovery
action and the completion effect cannot progress because it requires all rows authenticated.

workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[194-268]
workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[152-180]
workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[321-373]
workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[270-280]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Auto-auth cancels other providers’ in-flight authentication when one provider fails because `initialAuthFailed` is in the effect dependency array and is set inside the effect, triggering cleanup (`cancelled=true`). This can strand other providers in `pending` with no Retry button.

### Issue Context
- The initial auto-auth effect sets `initialAuthFailed` on first failure.
- `initialAuthFailed` is also a dependency, so the effect re-runs and previous cleanup runs.
- Pending rows never show Retry; completion requires all rows authenticated.

### Fix Focus Areas
- Ensure changing `initialAuthFailed` does **not** trigger cleanup for the ongoing auth run. Options:
 - Remove `initialAuthFailed` from the auto-auth effect dependency array (and keep the early-return guard via a ref), so the effect lifetime is tied only to `csvContent`/unmount.
 - Alternatively, delay setting `initialAuthFailed` until after all provider auth attempts settle, or keep a per-run `authRunId` ref and ignore stale completions without flipping `cancelled`.
- Consider explicitly marking any still-pending providers as `error` if you intend to stop auto-auth after the first failure.

- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[194-268]
- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[321-373]
- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[270-280]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Retry staleness after CSV 🐞 Bug ⛯ Reliability
Description
retryProvider applies async results (token + status) without checking whether csvContent changed or
the component unmounted, so a retry started for an old CSV can update the new provider status/token
map and potentially trigger setSecrets/onChange for the wrong state.
Code

workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[R282-312]

+  const retryProvider = useCallback(
+    async (provider: ScmProvider, readOnly: boolean) => {
+      setProviderStatuses(prev => new Map(prev).set(provider.name, 'pending'));
+      setProviderErrors(prev => {
+        const next = new Map(prev);
+        next.delete(provider.name);
+        return next;
+      });
+
+      try {
+        const tokens = await repoAuthRef.current.authenticate([
+          provider.getAuthTokenDescriptor(readOnly),
+        ]);
+
+        providerTokensRef.current.set(
+          `${SCAFFOLDER_SECRET_PREFIX}${provider.name}`,
+          tokens[0].token,
+        );
+        setProviderStatuses(prev =>
+          new Map(prev).set(provider.name, 'authenticated'),
+        );
+      } catch (e) {
+        setProviderStatuses(prev => new Map(prev).set(provider.name, 'error'));
+        setProviderErrors(prev =>
+          new Map(prev).set(
+            provider.name,
+            e instanceof Error ? e.message : 'Unknown error',
+          ),
+        );
+      }
+    },
Evidence
CSV changes reset providerStatuses/providerErrors/providerTokensRef, but retryProvider has no
snapshot/cancellation guard; if the user changes CSV during a retry, the old retry can resolve
afterward and write into the freshly reset maps (same refs/state setters). Since completion is
driven by providerRows status and providerTokensRef content, stale writes can incorrectly influence
completion/secrets if providers overlap between CSVs (e.g., github present in both, but access/scope
differs).

workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[182-192]
workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[282-312]
workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[270-280]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`retryProvider` can update status/errors/tokens after the CSV has changed (or after unmount), because it awaits an async authenticate call and then unconditionally writes to refs/state.

### Issue Context
- CSV change resets `providerTokensRef.current` and status/error maps.
- `retryProvider` has no `cancelled` flag or `csvContent` snapshot check.
- Completion/secrets are derived from `providerRows` statuses and `providerTokensRef.current`.

### Fix Focus Areas
- Add a per-run staleness guard for retries, similar to the auto-auth effect:
 - Capture a snapshot (e.g., `const retryCsvSnapshot = prevCsvRef.current`) when the retry starts.
 - Before writing status/tokens, verify `retryCsvSnapshot === prevCsvRef.current` and that the component is still mounted.
 - Alternatively, maintain an incrementing `authGenerationRef` bumped on CSV change/unmount; store the generation at retry start and ignore results if it no longer matches.
- Ensure error/status updates are skipped when stale.

- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[282-312]
- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[182-192]
- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[270-280]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

@mareklibra mareklibra marked this pull request as draft March 24, 2026 12:08
Comment on lines +194 to +268
useEffect(() => {
if (!csvContent || suppressDialog || isDone) {
if (!csvContent || initialAuthFailed || isDone || parseError) {
return undefined;
}

setError(undefined);
const targets = distinctTargetProviders;
const sources = distinctSourceProviders;
const allDistinctProviders = [...targets, ...sources];

let projectsToCreate;
try {
projectsToCreate = parseCsvContent(csvContent);
} catch (e) {
setError(e instanceof Error ? e.message : 'Unknown error');
if (allDistinctProviders.length === 0) {
return undefined;
}

const allTargetProviders: ScmProvider[] = projectsToCreate.map(project =>
resolveScmProvider(project.targetRepoUrl, hostProviderMap),
);
const allSourceProviders: ScmProvider[] = projectsToCreate.map(project =>
resolveScmProvider(project.sourceRepoUrl, hostProviderMap),
);
const distinctTargetProviders = allTargetProviders.filter(
(p, i, arr) => arr.findIndex(q => q.name === p.name) === i,
);
const distinctSourceProviders = allSourceProviders.filter(
(p, i, arr) =>
arr.findIndex(q => q.name === p.name) === i &&
!distinctTargetProviders.some(t => t.name === p.name),
);
const allDistinctProviders = [
...distinctTargetProviders,
...distinctSourceProviders,
];

let cancelled = false;
const authCsvSnapshot = csvContent;

const doAuthAsync = async () => {
const providerTokens = new Map<string, string>();

const authenticateProvider = async (
provider: ScmProvider,
readOnly: boolean,
) => {
try {
const tokens = await repoAuthentication.authenticate([
const tokens = await repoAuthRef.current.authenticate([
provider.getAuthTokenDescriptor(readOnly),
]);
if (cancelled) {
return;
}
providerTokens.set(
providerTokensRef.current.set(
`${SCAFFOLDER_SECRET_PREFIX}${provider.name}`,
tokens[0].token,
);
setProviderStatuses(prev =>
new Map(prev).set(provider.name, 'authenticated'),
);
} catch (e) {
if (cancelled) {
return;
}
setError(e instanceof Error ? e.message : 'Unknown error');
setSuppressDialog(true);
setProviderStatuses(prev =>
new Map(prev).set(provider.name, 'error'),
);
setProviderErrors(prev =>
new Map(prev).set(
provider.name,
e instanceof Error ? e.message : 'Unknown error',
),
);
setInitialAuthFailed(true);
}
};

await Promise.all([
...distinctTargetProviders.map(p => authenticateProvider(p, false)),
...distinctSourceProviders.map(p => authenticateProvider(p, true)),
...targets.map(p => authenticateProvider(p, false)),
...sources.map(p => authenticateProvider(p, true)),
]);

if (
cancelled ||
authCsvSnapshot !== prevCsvRef.current ||
providerTokens.size !== allDistinctProviders.length
) {
if (
!cancelled &&
authCsvSnapshot === prevCsvRef.current &&
providerTokens.size !== allDistinctProviders.length
) {
onChange(undefined);
}
if (cancelled || authCsvSnapshot !== prevCsvRef.current) {
return;
}

onChange('authenticated');
setDone(true);
setSecrets({
...secretsRef.current,
...Object.fromEntries(providerTokens),
});
if (providerTokensRef.current.size !== allDistinctProviders.length) {
onChangeRef.current(undefined);
}
};

doAuthAsync();

return () => {
cancelled = true;
};
}, [
csvContent,
hostProviderMap,
repoAuthentication,
setSecrets,
suppressDialog,
isDone,
onChange,
]);
// csvContent change is the meaningful trigger for re-authentication.
// distinctTarget/SourceProviders are derived from it and captured above.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [csvContent, initialAuthFailed, isDone, parseError]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

1. Pending rows after failure 🐞 Bug ⛯ Reliability

When any provider authentication fails, the component sets initialAuthFailed=true, which is also a
dependency of the auto-auth effect; this triggers effect cleanup (cancelled=true) while other
provider auth calls may still be in-flight, preventing those providers from ever setting
status/error. Those providers remain in 'pending' and do not render a Retry button, which can block
completion indefinitely.
Agent Prompt
### Issue description
Auto-auth cancels other providers’ in-flight authentication when one provider fails because `initialAuthFailed` is in the effect dependency array and is set inside the effect, triggering cleanup (`cancelled=true`). This can strand other providers in `pending` with no Retry button.

### Issue Context
- The initial auto-auth effect sets `initialAuthFailed` on first failure.
- `initialAuthFailed` is also a dependency, so the effect re-runs and previous cleanup runs.
- Pending rows never show Retry; completion requires all rows authenticated.

### Fix Focus Areas
- Ensure changing `initialAuthFailed` does **not** trigger cleanup for the ongoing auth run. Options:
  - Remove `initialAuthFailed` from the auto-auth effect dependency array (and keep the early-return guard via a ref), so the effect lifetime is tied only to `csvContent`/unmount.
  - Alternatively, delay setting `initialAuthFailed` until after all provider auth attempts settle, or keep a per-run `authRunId` ref and ignore stale completions without flipping `cancelled`.
- Consider explicitly marking any still-pending providers as `error` if you intend to stop auto-auth after the first failure.

- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[194-268]
- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[321-373]
- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[270-280]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this looks legit to.

Comment on lines +282 to +312
const retryProvider = useCallback(
async (provider: ScmProvider, readOnly: boolean) => {
setProviderStatuses(prev => new Map(prev).set(provider.name, 'pending'));
setProviderErrors(prev => {
const next = new Map(prev);
next.delete(provider.name);
return next;
});

try {
const tokens = await repoAuthRef.current.authenticate([
provider.getAuthTokenDescriptor(readOnly),
]);

providerTokensRef.current.set(
`${SCAFFOLDER_SECRET_PREFIX}${provider.name}`,
tokens[0].token,
);
setProviderStatuses(prev =>
new Map(prev).set(provider.name, 'authenticated'),
);
} catch (e) {
setProviderStatuses(prev => new Map(prev).set(provider.name, 'error'));
setProviderErrors(prev =>
new Map(prev).set(
provider.name,
e instanceof Error ? e.message : 'Unknown error',
),
);
}
},
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Action required

2. Retry staleness after csv 🐞 Bug ⛯ Reliability

retryProvider applies async results (token + status) without checking whether csvContent changed or
the component unmounted, so a retry started for an old CSV can update the new provider status/token
map and potentially trigger setSecrets/onChange for the wrong state.
Agent Prompt
### Issue description
`retryProvider` can update status/errors/tokens after the CSV has changed (or after unmount), because it awaits an async authenticate call and then unconditionally writes to refs/state.

### Issue Context
- CSV change resets `providerTokensRef.current` and status/error maps.
- `retryProvider` has no `cancelled` flag or `csvContent` snapshot check.
- Completion/secrets are derived from `providerRows` statuses and `providerTokensRef.current`.

### Fix Focus Areas
- Add a per-run staleness guard for retries, similar to the auto-auth effect:
  - Capture a snapshot (e.g., `const retryCsvSnapshot = prevCsvRef.current`) when the retry starts.
  - Before writing status/tokens, verify `retryCsvSnapshot === prevCsvRef.current` and that the component is still mounted.
  - Alternatively, maintain an incrementing `authGenerationRef` bumped on CSV change/unmount; store the generation at retry start and ignore results if it no longer matches.
- Ensure error/status updates are skipped when stale.

- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[282-312]
- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[182-192]
- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[270-280]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@mareklibra mareklibra force-pushed the FLPATH-3464.RepoAuthenticationSummary branch from 4a8bee6 to 94339bf Compare March 24, 2026 13:05
Signed-off-by: Marek Libra <marek.libra@gmail.com>
@mareklibra mareklibra force-pushed the FLPATH-3464.RepoAuthenticationSummary branch from 94339bf to a5623a0 Compare March 24, 2026 13:25
@mareklibra mareklibra marked this pull request as ready for review March 24, 2026 13:25
@rhdh-qodo-merge
Copy link
Copy Markdown

Review Summary by Qodo

RepoAuthentication renders summary table of required auth tokens

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Refactored RepoAuthentication to display provider summary table
• Added useProviderAuth hook for centralized auth state management
• Renders per-provider status, scope, and access level information
• Implements per-provider retry buttons with error handling
• Comprehensive test coverage for auth flows and edge cases
Diagram
flowchart LR
  CSV["CSV Content"] -->|parsed| useProviderAuth["useProviderAuth Hook"]
  useProviderAuth -->|extracts providers| ProviderRows["Provider Rows"]
  ProviderRows -->|displays| Table["Summary Table"]
  Table -->|shows| Status["Status + Scope + Access"]
  useProviderAuth -->|manages| Auth["Auth State"]
  Auth -->|on success| Secrets["Store Secrets"]
  Auth -->|on error| Retry["Per-Provider Retry"]
Loading

Grey Divider

File Changes

1. workspaces/x2a/plugins/x2a/src/scaffolder/useProviderAuth.ts ✨ Enhancement +297/-0

New hook for centralized provider auth state management

• New custom hook that encapsulates all provider authentication logic
• Manages provider status tracking (pending, authenticated, error)
• Implements auto-authentication on CSV change with cancellation support
• Provides per-provider retry mechanism with generation-based staleness detection
• Handles concurrent auth requests and deduplicates distinct providers

workspaces/x2a/plugins/x2a/src/scaffolder/useProviderAuth.ts


2. workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx ✨ Enhancement +100/-145

Refactored to display provider authentication summary table

• Refactored to use new useProviderAuth hook instead of inline logic
• Added Material-UI Table component to display provider summary
• Renders provider name, access level (Read-only/Read-Write), OAuth scope, and status
• Integrated StatusIcon component for visual status indicators
• Replaced global "Try again" button with per-row Retry buttons for error states
• Simplified component by delegating state management to hook

workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx


3. workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.test.tsx 🧪 Tests +1348/-103

Comprehensive test coverage for auth flows and edge cases

• Expanded test suite from ~100 to ~1400 lines with comprehensive coverage
• Added Material-UI ThemeProvider wrapper for status color rendering
• Introduced makeProps helper and async renderComponent for cleaner test setup
• Added flushAsync utility to properly settle async effects in tests
• New test categories: provider table rendering, status tracking, concurrent resilience, retry
 staleness, unmount safety, CSV lifecycle edge cases, completion effects, provider deduplication
• Tests cover error handling, retry logic, state reset on CSV change, and mixed provider scenarios

workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.test.tsx


View more (2)
4. workspaces/x2a/templates/conversion-project-template.yaml ⚙️ Configuration changes +2/-0

Make repoAuthentication required for CSV input method

• Added repoAuthentication to required fields when CSV input method is selected
• Ensures users must complete authentication before proceeding with CSV import

workspaces/x2a/templates/conversion-project-template.yaml


5. workspaces/x2a/.changeset/long-baboons-dig.md 📝 Documentation +5/-0

Changeset for RepoAuthentication summary feature

• Changelog entry documenting the addition of summary display to RepoAuthentication widget

workspaces/x2a/.changeset/long-baboons-dig.md


Grey Divider

Qodo Logo

@rhdh-qodo-merge
Copy link
Copy Markdown

rhdh-qodo-merge bot commented Mar 24, 2026

Code Review by Qodo

🐞 Bugs (2) 📘 Rule violations (0) 📎 Requirement gaps (0) 📐 Spec deviations (0)

Grey Divider


Action required

1. Retry staleness after CSV 🐞 Bug ⛯ Reliability
Description
retryProvider applies async results (token + status) without checking whether csvContent changed or
the component unmounted, so a retry started for an old CSV can update the new provider status/token
map and potentially trigger setSecrets/onChange for the wrong state.
Code

workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[R282-312]

+  const retryProvider = useCallback(
+    async (provider: ScmProvider, readOnly: boolean) => {
+      setProviderStatuses(prev => new Map(prev).set(provider.name, 'pending'));
+      setProviderErrors(prev => {
+        const next = new Map(prev);
+        next.delete(provider.name);
+        return next;
+      });
+
+      try {
+        const tokens = await repoAuthRef.current.authenticate([
+          provider.getAuthTokenDescriptor(readOnly),
+        ]);
+
+        providerTokensRef.current.set(
+          `${SCAFFOLDER_SECRET_PREFIX}${provider.name}`,
+          tokens[0].token,
+        );
+        setProviderStatuses(prev =>
+          new Map(prev).set(provider.name, 'authenticated'),
+        );
+      } catch (e) {
+        setProviderStatuses(prev => new Map(prev).set(provider.name, 'error'));
+        setProviderErrors(prev =>
+          new Map(prev).set(
+            provider.name,
+            e instanceof Error ? e.message : 'Unknown error',
+          ),
+        );
+      }
+    },
Evidence
CSV changes reset providerStatuses/providerErrors/providerTokensRef, but retryProvider has no
snapshot/cancellation guard; if the user changes CSV during a retry, the old retry can resolve
afterward and write into the freshly reset maps (same refs/state setters). Since completion is
driven by providerRows status and providerTokensRef content, stale writes can incorrectly influence
completion/secrets if providers overlap between CSVs (e.g., github present in both, but access/scope
differs).

workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[182-192]
workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[282-312]
workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[270-280]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
`retryProvider` can update status/errors/tokens after the CSV has changed (or after unmount), because it awaits an async authenticate call and then unconditionally writes to refs/state.

### Issue Context
- CSV change resets `providerTokensRef.current` and status/error maps.
- `retryProvider` has no `cancelled` flag or `csvContent` snapshot check.
- Completion/secrets are derived from `providerRows` statuses and `providerTokensRef.current`.

### Fix Focus Areas
- Add a per-run staleness guard for retries, similar to the auto-auth effect:
 - Capture a snapshot (e.g., `const retryCsvSnapshot = prevCsvRef.current`) when the retry starts.
 - Before writing status/tokens, verify `retryCsvSnapshot === prevCsvRef.current` and that the component is still mounted.
 - Alternatively, maintain an incrementing `authGenerationRef` bumped on CSV change/unmount; store the generation at retry start and ignore results if it no longer matches.
- Ensure error/status updates are skipped when stale.

- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[282-312]
- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[182-192]
- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[270-280]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. Pending rows after failure 🐞 Bug ⛯ Reliability
Description
When any provider authentication fails, the component sets initialAuthFailed=true, which is also a
dependency of the auto-auth effect; this triggers effect cleanup (cancelled=true) while other
provider auth calls may still be in-flight, preventing those providers from ever setting
status/error. Those providers remain in 'pending' and do not render a Retry button, which can block
completion indefinitely.
Code

workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[R194-268]

  useEffect(() => {
-    if (!csvContent || suppressDialog || isDone) {
+    if (!csvContent || initialAuthFailed || isDone || parseError) {
      return undefined;
    }

-    setError(undefined);
+    const targets = distinctTargetProviders;
+    const sources = distinctSourceProviders;
+    const allDistinctProviders = [...targets, ...sources];

-    let projectsToCreate;
-    try {
-      projectsToCreate = parseCsvContent(csvContent);
-    } catch (e) {
-      setError(e instanceof Error ? e.message : 'Unknown error');
+    if (allDistinctProviders.length === 0) {
      return undefined;
    }

-    const allTargetProviders: ScmProvider[] = projectsToCreate.map(project =>
-      resolveScmProvider(project.targetRepoUrl, hostProviderMap),
-    );
-    const allSourceProviders: ScmProvider[] = projectsToCreate.map(project =>
-      resolveScmProvider(project.sourceRepoUrl, hostProviderMap),
-    );
-    const distinctTargetProviders = allTargetProviders.filter(
-      (p, i, arr) => arr.findIndex(q => q.name === p.name) === i,
-    );
-    const distinctSourceProviders = allSourceProviders.filter(
-      (p, i, arr) =>
-        arr.findIndex(q => q.name === p.name) === i &&
-        !distinctTargetProviders.some(t => t.name === p.name),
-    );
-    const allDistinctProviders = [
-      ...distinctTargetProviders,
-      ...distinctSourceProviders,
-    ];
-
    let cancelled = false;
    const authCsvSnapshot = csvContent;

    const doAuthAsync = async () => {
-      const providerTokens = new Map<string, string>();
-
      const authenticateProvider = async (
        provider: ScmProvider,
        readOnly: boolean,
      ) => {
        try {
-          const tokens = await repoAuthentication.authenticate([
+          const tokens = await repoAuthRef.current.authenticate([
            provider.getAuthTokenDescriptor(readOnly),
          ]);
          if (cancelled) {
            return;
          }
-          providerTokens.set(
+          providerTokensRef.current.set(
            `${SCAFFOLDER_SECRET_PREFIX}${provider.name}`,
            tokens[0].token,
          );
+          setProviderStatuses(prev =>
+            new Map(prev).set(provider.name, 'authenticated'),
+          );
        } catch (e) {
          if (cancelled) {
            return;
          }
-          setError(e instanceof Error ? e.message : 'Unknown error');
-          setSuppressDialog(true);
+          setProviderStatuses(prev =>
+            new Map(prev).set(provider.name, 'error'),
+          );
+          setProviderErrors(prev =>
+            new Map(prev).set(
+              provider.name,
+              e instanceof Error ? e.message : 'Unknown error',
+            ),
+          );
+          setInitialAuthFailed(true);
        }
      };

      await Promise.all([
-        ...distinctTargetProviders.map(p => authenticateProvider(p, false)),
-        ...distinctSourceProviders.map(p => authenticateProvider(p, true)),
+        ...targets.map(p => authenticateProvider(p, false)),
+        ...sources.map(p => authenticateProvider(p, true)),
      ]);

-      if (
-        cancelled ||
-        authCsvSnapshot !== prevCsvRef.current ||
-        providerTokens.size !== allDistinctProviders.length
-      ) {
-        if (
-          !cancelled &&
-          authCsvSnapshot === prevCsvRef.current &&
-          providerTokens.size !== allDistinctProviders.length
-        ) {
-          onChange(undefined);
-        }
+      if (cancelled || authCsvSnapshot !== prevCsvRef.current) {
        return;
      }

-      onChange('authenticated');
-      setDone(true);
-      setSecrets({
-        ...secretsRef.current,
-        ...Object.fromEntries(providerTokens),
-      });
+      if (providerTokensRef.current.size !== allDistinctProviders.length) {
+        onChangeRef.current(undefined);
+      }
    };

    doAuthAsync();

    return () => {
      cancelled = true;
    };
-  }, [
-    csvContent,
-    hostProviderMap,
-    repoAuthentication,
-    setSecrets,
-    suppressDialog,
-    isDone,
-    onChange,
-  ]);
+    // csvContent change is the meaningful trigger for re-authentication.
+    // distinctTarget/SourceProviders are derived from it and captured above.
+    // eslint-disable-next-line react-hooks/exhaustive-deps
+  }, [csvContent, initialAuthFailed, isDone, parseError]);
Evidence
The auto-auth effect depends on initialAuthFailed and sets it to true on the first provider failure;
React will run the previous effect’s cleanup when dependencies change, setting cancelled=true. Any
other provider auth promise that resolves after that will hit if (cancelled) return; and therefore
never updates providerStatuses/providerErrors, leaving that row at the default 'pending' state. The
UI only shows the Retry button for rows with status==='error', so a pending row has no recovery
action and the completion effect cannot progress because it requires all rows authenticated.

workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[194-268]
workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[152-180]
workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[321-373]
workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[270-280]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

### Issue description
Auto-auth cancels other providers’ in-flight authentication when one provider fails because `initialAuthFailed` is in the effect dependency array and is set inside the effect, triggering cleanup (`cancelled=true`). This can strand other providers in `pending` with no Retry button.

### Issue Context
- The initial auto-auth effect sets `initialAuthFailed` on first failure.
- `initialAuthFailed` is also a dependency, so the effect re-runs and previous cleanup runs.
- Pending rows never show Retry; completion requires all rows authenticated.

### Fix Focus Areas
- Ensure changing `initialAuthFailed` does **not** trigger cleanup for the ongoing auth run. Options:
 - Remove `initialAuthFailed` from the auto-auth effect dependency array (and keep the early-return guard via a ref), so the effect lifetime is tied only to `csvContent`/unmount.
 - Alternatively, delay setting `initialAuthFailed` until after all provider auth attempts settle, or keep a per-run `authRunId` ref and ignore stale completions without flipping `cancelled`.
- Consider explicitly marking any still-pending providers as `error` if you intend to stop auto-auth after the first failure.

- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[194-268]
- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[321-373]
- workspaces/x2a/plugins/x2a/src/scaffolder/RepoAuthentication.tsx[270-280]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

return undefined;
}

let cancelled = false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you explain this cancelled thing? I read the code multiple times, and I'm still not sure what it does to be honest, because if one is cancelled the behaviour is just weird, no? can we make this code a bit simpler and with a few comments?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can add more comments here. I agree this is not a simple reading, it requires non trivial knowledge of React, especially the part around refs.

The highlighted part is a corner case. If the component gets unmounted during the async flows, it will not set stale data.

Considering the component is on a scaffolder wizard page only, this is a rare case. Practically, it prevents transitive setting stale scaffolder secrets and annoying error logs in the console.

This hook is already result of an attempt to simplify the flow via removing and consolidating the logic. The RepoAuthentication component became more presentational this way.

It used to be a bit simpler with former just a single button "Try all again". But the recent multiple "Retry" look better to the user since we have the explicit list of providers to authenticate to.

Anyway, adding additional supported providers should be very simple and that's what is the most important probably. The tests seem to be comprehensive, so I would not be afraid of future extensions here.

@sonarqubecloud
Copy link
Copy Markdown

@eloycoto eloycoto merged commit 2bf13d3 into redhat-developer:main Mar 26, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants