Skip to content

Commit

Permalink
add support for orgs and team view
Browse files Browse the repository at this point in the history
Signed-off-by: Alisson Fabiano <afabiano@eshopworld.com>
  • Loading branch information
esw-afabiano committed Sep 24, 2024
1 parent 500c720 commit 61eb8c1
Show file tree
Hide file tree
Showing 76 changed files with 2,811 additions and 938 deletions.
7 changes: 7 additions & 0 deletions workspaces/copilot/.changeset/six-cats-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
'@backstage-community/plugin-copilot-backend': major
'@backstage-community/plugin-copilot-common': major
'@backstage-community/plugin-copilot': major
---

Introduced support for organizations and team metrics visualization in the Copilot plugin.
5 changes: 2 additions & 3 deletions workspaces/copilot/packages/app/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ import { AppRouter, FlatRoutes } from '@backstage/core-app-api';
import { CatalogGraphPage } from '@backstage/plugin-catalog-graph';
import { RequirePermission } from '@backstage/plugin-permission-react';
import { catalogEntityCreatePermission } from '@backstage/plugin-catalog-common/alpha';
import { CopilotPage } from '@backstage-community/plugin-copilot';
import { CopilotIndexPage } from '@backstage-community/plugin-copilot';

const app = createApp({
apis,
Expand Down Expand Up @@ -111,10 +111,9 @@ const routes = (
</Route>
<Route path="/settings" element={<UserSettingsPage />} />
<Route path="/catalog-graph" element={<CatalogGraphPage />} />
<Route path="/copilot" element={<CopilotPage />} />
<Route path="/copilot" element={<CopilotIndexPage />} />
</FlatRoutes>
);

export default app.createRoot(
<>
<AlertDisplay />
Expand Down
4 changes: 2 additions & 2 deletions workspaces/copilot/packages/app/src/components/Root/Root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import HomeIcon from '@material-ui/icons/Home';
import ExtensionIcon from '@material-ui/icons/Extension';
import LibraryBooks from '@material-ui/icons/LibraryBooks';
import CreateComponentIcon from '@material-ui/icons/AddCircleOutline';
import GithubIcon from '@material-ui/icons/GitHub';
import LogoFull from './LogoFull';
import LogoIcon from './LogoIcon';
import {
Expand All @@ -41,6 +40,7 @@ import {
} from '@backstage/core-components';
import MenuIcon from '@material-ui/icons/Menu';
import SearchIcon from '@material-ui/icons/Search';
import { CopilotSidebar } from '@backstage-community/plugin-copilot';

const useSidebarLogoStyles = makeStyles({
root: {
Expand Down Expand Up @@ -87,7 +87,7 @@ export const Root = ({ children }: PropsWithChildren<{}>) => (
{/* End global nav */}
<SidebarDivider />
<SidebarScrollWrapper>
<SidebarItem icon={GithubIcon} to="copilot" text="Copilot" />
<CopilotSidebar />
</SidebarScrollWrapper>
</SidebarGroup>
<SidebarSpace />
Expand Down
54 changes: 37 additions & 17 deletions workspaces/copilot/plugins/copilot-backend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ To configure the plugin using the new backend system:

const backend = createBackend();

backend.add(import('@backstage-community/plugin-copilot'));
backend.add(import('@backstage-community/plugin-copilot-backend'));

backend.start();
```
Expand All @@ -24,17 +24,17 @@ To configure the plugin using the new backend system:

To install the plugin using the old method:

1. Add the `@backstage-community/plugin-copilot` package to your backend:
1. Add the `@backstage-community/plugin-copilot-backend` package to your backend:

```sh
yarn --cwd packages/backend add @backstage-community/plugin-copilot
yarn --cwd packages/backend add @backstage-community/plugin-copilot-backend
```

2. In your `packages/backend/src/plugins/copilot.ts` file, add the following code:

```typescript
import { TaskScheduleDefinition } from '@backstage/backend-tasks';
import { createRouterFromConfig } from '@backstage-community/plugin-copilot';
import { createRouterFromConfig } from '@backstage-community/plugin-copilot-backend';
export default async function createPlugin(): Promise<void> {
const schedule: TaskScheduleDefinition = {
Expand All @@ -53,9 +53,7 @@ To install the plugin using the old method:
import { createRouterFromConfig } from './plugins/copilot';
async function main() {
// Backend setup
const env = createEnv('copilot');
// Plugin registration
apiRouter.use('/copilot', await createRouterFromConfig(env));
}
```
Expand All @@ -68,14 +66,36 @@ To configure the GitHub Copilot plugin, you need to set the following environmen

- **`copilot.host`**: The host URL for your GitHub Copilot instance (e.g., `github.com` or `github.enterprise.com`).
- **`copilot.enterprise`**: The name of your GitHub Enterprise instance (e.g., `my-enterprise`).
- **`copilot.organization`**: The name of your GitHub Organization (e.g., `my-organization`).

These variables are used to configure the plugin and ensure it communicates with the correct GitHub instance.

### GitHub Credentials

**Important:** The GitHub token, which is necessary for authentication, should be managed within your Backstage integrations configuration. The token must be added to your GitHub integration settings, and the plugin will retrieve it through the `GithubCredentialsProvider`.
**Important:** The GitHub token, necessary for authentication, should be managed within your Backstage integrations configuration. Ensure that your GitHub integration in the Backstage configuration includes the necessary token for the `GithubCredentialsProvider` to function correctly.

Ensure that your GitHub integration in the Backstage configuration includes the necessary token for the `GithubCredentialsProvider` to work correctly.
### GitHub Token Scopes

To ensure the GitHub Copilot plugin operates correctly within your organization or enterprise, your GitHub access token must include specific scopes. These scopes grant the plugin the necessary permissions to interact with your GitHub organization and manage Copilot usage.

#### Required Scopes

1. **List Teams Endpoint**

- **Scope Required:** `read:org`
- **Purpose:** Allows the plugin to list all teams within your GitHub organization.

2. **Copilot Usage**
- **Scopes Required - enterprise:** `manage_billing:copilot`, `read:enterprise`
- **Scopes Required - organization:** `manage_billing:copilot`, `read:org`, or `read:enterprise`
- **Purpose:** Enables the plugin to manage and monitor GitHub Copilot usage within your organization or/and enterprise.

#### How to Configure Token Scopes

1. **Generate a Personal Access Token (PAT):**
- Navigate to [GitHub Personal Access Tokens](https://github.com/settings/tokens).
- Click on **Generate new token**.
- Select the scopes according to your needs

### YAML Configuration Example

Expand All @@ -90,20 +110,20 @@ copilot:
seconds: 15
host: YOUR_GITHUB_HOST_HERE
enterprise: YOUR_ENTERPRISE_NAME_HERE
```

### Generating GitHub Copilot Token
organization: YOUR_ORGANIZATION_NAME_HERE
To generate an access token for using GitHub Copilot:

- Visit [Generate GitHub Access Token](https://github.com/settings/tokens).
- Follow the instructions to create a new token with the `read:enterprise` scope.
integrations:
github:
- host: YOUR_GITHUB_HOST_HERE
token: YOUR_GENERATED_TOKEN
```

### API Documentation

For more details on using the GitHub Copilot API:
For more details on using the GitHub Copilot and Teams APIs, refer to the following documentation:

- Refer to the [API documentation](https://docs.github.com/en/rest/copilot/copilot-usage?apiVersion=2022-11-28) for comprehensive information on available functionalities.
- [GitHub Teams API - List Teams](https://docs.github.com/en/rest/teams/teams?apiVersion=2022-11-28#list-teams)
- [GitHub Copilot API - Usage](https://docs.github.com/en/rest/copilot/copilot-usage?apiVersion=2022-11-28)

## Run

Expand Down
4 changes: 4 additions & 0 deletions workspaces/copilot/plugins/copilot-backend/config.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,10 @@ export interface Config {
* The name of the GitHub enterprise.
*/
enterprise?: string;
/**
* The name of the GitHub organization.
*/
organization?: string;
/**
* The host for GitHub Copilot integration.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
/*
* Copyright 2024 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
/**
* @param {import('knex').Knex} knex
*/
exports.up = async function up(knex) {
await knex.schema.table('metrics', table => {
table
.string('type', 50)
.defaultTo('enterprise')
.notNullable()
.comment('Type of the metrics data: enterprise, organization');

table.string('team_name', 255).nullable().comment('Name of the team');
});

await knex.schema.table('metrics', table => {
table.dropPrimary();
});

await knex.schema.table('metrics', table => {
table.unique(['day', 'type', 'team_name'], 'uk_day_type_team_name');
});
};

/**
* @param {import('knex').Knex} knex
*/
exports.down = async function down(knex) {
await knex.schema.table('metrics', table => {
table.dropUnique(['day', 'type', 'team_name']);
});

await knex.schema.table('metrics', table => {
table.dropColumn('type');
table.dropColumn('team_name');
});

await knex.schema.table('metrics', table => {
table.primary('day');
table.index('day', 'idx_metrics_day');
});
};
1 change: 1 addition & 0 deletions workspaces/copilot/plugins/copilot-backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"@types/express": "*",
"express": "^4.17.1",
"express-promise-router": "^4.1.0",
"joi": "^17.13.3",
"knex": "^3.0.0",
"luxon": "^3.5.0",
"node-fetch": "^2.6.7",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,17 @@

import { ResponseError } from '@backstage/errors';
import { Config } from '@backstage/config';
import { Metric } from '@backstage-community/plugin-copilot-common';
import { Metric, TeamInfo } from '@backstage-community/plugin-copilot-common';
import fetch from 'node-fetch';
import { getGithubInfo, GithubInfo } from '../utils/GithubUtils';

interface GithubApi {
getCopilotUsageDataForEnterprise: () => Promise<Metric[]>;
fetchEnterpriseCopilotUsage: () => Promise<Metric[]>;
fetchEnterpriseTeamCopilotUsage: (teamId: string) => Promise<Metric[]>;
fetchEnterpriseTeams: () => Promise<TeamInfo[]>;
fetchOrganizationCopilotUsage: () => Promise<Metric[]>;
fetchOrganizationTeamCopilotUsage: (teamId: string) => Promise<Metric[]>;
fetchOrganizationTeams: () => Promise<TeamInfo[]>;
}

export class GithubClient implements GithubApi {
Expand All @@ -32,11 +37,36 @@ export class GithubClient implements GithubApi {
return new GithubClient(info);
}

async getCopilotUsageDataForEnterprise(): Promise<Metric[]> {
async fetchEnterpriseCopilotUsage(): Promise<Metric[]> {
const path = `/enterprises/${this.props.enterprise}/copilot/usage`;
return this.get(path);
}

async fetchEnterpriseTeamCopilotUsage(teamId: string): Promise<Metric[]> {
const path = `/enterprises/${this.props.enterprise}/team/${teamId}/copilot/usage`;
return this.get(path);
}

async fetchEnterpriseTeams(): Promise<TeamInfo[]> {
const path = `/enterprises/${this.props.enterprise}/teams`;
return this.get(path);
}

async fetchOrganizationCopilotUsage(): Promise<Metric[]> {
const path = `/orgs/${this.props.organization}/copilot/usage`;
return this.get(path);
}

async fetchOrganizationTeamCopilotUsage(teamId: string): Promise<Metric[]> {
const path = `/orgs/${this.props.organization}/team/${teamId}/copilot/usage`;
return this.get(path);
}

async fetchOrganizationTeams(): Promise<TeamInfo[]> {
const path = `/orgs/${this.props.organization}/teams`;
return this.get(path);
}

private async get<T>(path: string): Promise<T> {
const response = await fetch(`${this.props.apiBaseUrl}${path}`, {
headers: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
} from '@backstage/backend-plugin-api';
import {
Metric,
MetricsType,
PeriodRange,
} from '@backstage-community/plugin-copilot-common';
import { Knex } from 'knex';
Expand Down Expand Up @@ -53,42 +54,71 @@ export class DatabaseHandler {

private constructor(private readonly db: Knex) {}

async getMetricsByPeriod(
async getByPeriod(
startDate: string,
endDate: string,
type: MetricsType,
teamName?: string,
): Promise<MetricDbRow[]> {
const records = await this.db<MetricDbRow>('metrics').whereBetween('day', [
startDate,
endDate,
]);
let query = this.db<MetricDbRow>('metrics').where('type', type);

if (teamName) {
query = query.where('team_name', teamName);
} else {
query = query.whereNull('team_name');
}

const records = await query.whereBetween('day', [startDate, endDate]);

return records ?? [];
}

async getPeriodRange(): Promise<PeriodRange | undefined> {
const minDate = await this.db<MetricDbRow>('metrics')
.orderBy('day', 'asc')
.first('day');
const maxDate = await this.db<MetricDbRow>('metrics')
.orderBy('day', 'desc')
.first('day');
async getPeriodRange(type: MetricsType): Promise<PeriodRange | undefined> {
const query = this.db<MetricDbRow>('metrics').where('type', type);

const minDate = await query.orderBy('day', 'asc').first('day');
const maxDate = await query.orderBy('day', 'desc').first('day');

if (!minDate?.day || !maxDate?.day) return undefined;

return { minDate: minDate.day, maxDate: maxDate.day };
}

async getTeams(
type: MetricsType,
startDate: string,
endDate: string,
): Promise<Array<string | undefined>> {
const result = await this.db<MetricDbRow>('metrics')
.where('type', type)
.whereBetween('day', [startDate, endDate])
.whereNotNull('team_name')
.distinct('team_name')
.orderBy('team_name', 'asc')
.select('team_name');

return result.map(x => x.team_name);
}

async batchInsert(metrics: MetricDbRow[]): Promise<void> {
await this.db<MetricDbRow[]>('metrics')
.insert(metrics)
.onConflict('day')
.onConflict(['day', 'type', 'team_name'])
.ignore();
}

async getMostRecentDayFromMetrics(): Promise<string | undefined> {
async getMostRecentDayFromMetrics(
type: MetricsType,
teamName?: string,
): Promise<string | undefined> {
try {
const mostRecent = await this.db<MetricDbRow>('metrics')
.orderBy('day', 'desc')
.first('day');
let query = this.db<MetricDbRow>('metrics').where('type', type);

if (teamName) {
query = query.where('team_name', teamName);
}

const mostRecent = await query.orderBy('day', 'desc').first('day');

return mostRecent ? mostRecent.day : undefined;
} catch (e) {
Expand Down
Loading

0 comments on commit 61eb8c1

Please sign in to comment.