Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@backstage-community/plugin-github-issues': patch
---

Fixed the GitHub Issues card crashing with `TypeError: Failed to construct 'URL': Invalid URL` for `Group` and `User` entities whose `backstage.io/source-location` / `backstage.io/managed-by-location` is not a parseable URL (for example entities registered from a `file:` location, or with no location at all).

For owner entities (`Group`/`User`) the card now resolves the GitHub host from the configured `integrations.github` instead of the owner entity's location annotation — the same approach used by the `github-pull-requests-board` plugin — so a team simply sees the open issues of every repository it owns. `getHostnameFromEntity` is now non-throwing, and repositories whose host cannot be determined are no longer silently dropped.
9 changes: 7 additions & 2 deletions workspaces/github/plugins/github-issues/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,13 @@ Welcome to the GitHub Issues plugin!
> Backstage UI (BUI) is now required for the GitHub Issues plugin to function, BUI has been included as part of Backstage since `1.41.0` which means you're very likely to already have it installed. The [BUI documentation](https://ui.backstage.io/) has details on installation if needed and the Backstage [User Interface documentation](https://backstage.io/docs/conf/user-interface/) has details on creating a custom BUI theme.

Based on the [well-known GitHub slug annotation](https://backstage.io/docs/features/software-catalog/well-known-annotations#githubcomproject-slug) associated with the Entity, it renders the list of Open issues in GitHub.
The plugin will attempt to determine the source code location using the [well-known Source location slug annotation](https://backstage.io/docs/features/software-catalog/well-known-annotations/#backstageiosource-location) or [Managed by location slug annotation](https://backstage.io/docs/features/software-catalog/well-known-annotations/#backstageiomanaged-by-location) associated with the Entity.
If no configured Github provider will match, the first one will be used.

The GitHub host (and therefore which `integrations.github` credentials are used) is resolved depending on the Entity kind:

- **Group / User**: the host is taken from the configured GitHub integration, **not** from the owner entity's location annotation. This means owners ingested from a non-GitHub source — e.g. Microsoft Entra / Microsoft Graph (`msgraph:`), LDAP, or a static `org.yaml` — work correctly and no longer crash the card. The repositories are taken from the [`github.com/project-slug`](https://backstage.io/docs/features/software-catalog/well-known-annotations#githubcomproject-slug) annotation of the Components the Entity owns, so a `github.com/team-slug` annotation on the Group/User is not required.
- **All other kinds** (e.g. Component): the host is determined from the entity's [Source location](https://backstage.io/docs/features/software-catalog/well-known-annotations/#backstageiosource-location) or [Managed by location](https://backstage.io/docs/features/software-catalog/well-known-annotations/#backstageiomanaged-by-location) annotation.

If no configured GitHub provider matches the resolved host, the first configured integration is used.

The plugin is designed to work with any Entity kind, and it behaves a bit differently depending on the target kind:

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ import { ScmAuthApi } from '@backstage/integration-react';
/** @internal */
export type Repository = {
name: string;
locationHostname: string;
locationHostname?: string;
};
/** @internal */
export type Assignee = {
Expand Down Expand Up @@ -115,7 +115,7 @@ export const githubIssuesApi = (
configApi: ConfigApi,
errorApi: ErrorApi,
) => {
const getOctokit = async (hostname: string) => {
const getOctokit = async (hostname?: string) => {
const configs = readGithubIntegrationConfigs(
configApi.getOptionalConfigArray('integrations.github') ?? [],
);
Expand All @@ -140,7 +140,7 @@ export const githubIssuesApi = (
const fetchIssuesByRepoFromGithub = async (
repos: Array<Repository>,
itemsPerRepo: number,
hostname: string,
hostname?: string,
{
filterBy,
orderBy = {
Expand All @@ -152,8 +152,11 @@ export const githubIssuesApi = (
const { octokit, host } = await getOctokit(hostname);
const safeNames: Array<string> = [];
Comment thread
PadreSVK marked this conversation as resolved.
const repositories = repos
// only tries to fetch issues from repositories that are hosted on the same GitHub instance as the octokit
.filter(repo => repo.locationHostname === host)
// Only fetch issues from repositories hosted on the same GitHub instance
// as the octokit. Repositories whose host could not be determined (no
// usable location annotation) are assumed to live on the resolved host,
// so they are kept rather than silently dropped.
.filter(repo => !repo.locationHostname || repo.locationHostname === host)
.map(repo => {
const [owner, name] = repo.name.split('/');

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,64 @@ describe('GithubIssues', () => {
);
});

it('should render Group issues when the group has no annotations (no team-slug or location)', async () => {
// Regression test: a Group with no annotations at all. Previously the card
// crashed with "Failed to construct 'URL': Invalid URL" because it parsed
// the group's source/managed-by location to determine the host. The host
// is now resolved from the configured integration, and the repositories
// come from the components owned by the group (their github.com/project-slug),
// so neither github.com/team-slug nor a location annotation is required.
const testIssue = getTestIssue({
repository: { nameWithOwner: 'backstage/owned-repo' },
});

const ownedComponent = makeEntityWithKind('Component', {
name: 'owned-repo',
spec: { type: 'service', lifecycle: 'production', owner: 'my-team' },
});

const catalogApiWithOwned = {
getEntities: async () => ({ items: [ownedComponent] }),
} as CatalogApi;

const groupWithoutAnnotations = {
metadata: {
name: 'my-team',
},
spec: { type: 'team', children: [] },
apiVersion: 'backstage.io/v1alpha1',
kind: 'Group',
} as unknown as Entity;

const mockApi: GithubIssuesApi = {
fetchIssuesByRepoFromGithub: async () => ({
'owned-repo': {
issues: {
totalCount: 1,
edges: [testIssue],
},
},
}),
} as GithubIssuesApi;

const apis = [
[githubIssuesApiRef, mockApi],
[catalogApiRef, catalogApiWithOwned],
] as const;

const { getByTestId } = await renderInTestApp(
<TestApiProvider apis={apis}>
<EntityProvider entity={groupWithoutAnnotations}>
<GithubIssues />
</EntityProvider>
</TestApiProvider>,
);

expect(getByTestId(`issue-${testIssue.node.url}`)).toHaveTextContent(
testIssue.node.title,
);
});

it('should render correctly when there are no issues in GitHub for kind: User', async () => {
const ownedComponent = makeEntityWithKind('Component', {
name: 'owned-repo',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -30,9 +30,22 @@ export const getProjectNameFromEntity = (entity: Entity): string => {
return entity?.metadata.annotations?.[GITHUB_PROJECT_SLUG_ANNOTATION] ?? '';
};

export const getHostnameFromEntity = (entity: Entity): string => {
const { target } = getEntitySourceLocation(entity);
return new URL(target).hostname;
export const getHostnameFromEntity = (entity: Entity): string | undefined => {
try {
const { target } = getEntitySourceLocation(entity);
// The location type (`url`, `github`, `gitlab`, ...) is not a reliable
// signal here: a non-`url` type can still carry a parseable URL target.
// Attempt to parse the host regardless of type and let the `catch` handle
// targets that are not URLs.
return new URL(target).hostname;
} catch {
// The entity has no usable URL source/managed-by location (e.g. it was
// registered from a non-URL location such as `file:`, or has no location
// annotation at all). The GitHub host is resolved from the configured
// integration instead, so fall back to `undefined` rather than throwing
// and crashing the card with "Failed to construct 'URL': Invalid URL".
return undefined;
}
};

export function useEntityGithubRepositories() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,14 @@ export const useGetIssuesByRepoFromGithub = (
) => {
const githubIssuesApi = useApi(githubIssuesApiRef);
const { entity } = useEntity();
const hostname = getHostnameFromEntity(entity);
// For Group/User entities the issues come from the *owned* repositories, not
// from the owner entity itself. Its own source/managed-by location therefore
// must not constrain the GitHub host — and is frequently a non-URL location
// that used to crash the card. Let the API resolve the host from the
// configured `integrations.github`, the same way the
// github-pull-requests-board plugin does.
const isOwnerEntity = entity.kind === 'Group' || entity.kind === 'User';
const hostname = isOwnerEntity ? undefined : getHostnameFromEntity(entity);

const {
value: issues,
Expand Down
Loading