diff --git a/README.md b/README.md index 4792933..2dd745c 100644 --- a/README.md +++ b/README.md @@ -53,29 +53,38 @@ The reports available in this repository are noted below ### Copilot Associations -The purpose of [this report](./src/report/copilot-associations-report.ts) is to show what users exist in an organizations teams that do not have a copilot seat in the org but may have a relation to someone else on the team that does have a copilot seat. +The purpose of [this report](./src/report/copilot-associations-report.ts) is to show what users exist in an organizations teams that do not have a copilot seat in the org but may have a relation (aka association) to someone else on the team that does have a copilot seat. -A CSV file named `copilot_associations.csv` is generated for this report containing the data. You will see the following values in the report: +There are two reports that are generated as a result of running this report and the names of these files can be managed via [settings](#optional-settings): -* **org_name**: Name of the organization the report was run for -* **user_name**: Name of a user found in the org as either a Team Member in one of the orgs teams or an active user in one of the repositories found in the org -* **user_has_org_copilot_seat**: whether or not the user has been assigned a copilot seat directly by the org. This does not indicate if the user may have been assigned a copilot seat by another org -* **association**: The association the user has noted in this report. This will either be the name of a Team in the org or the name of a Repository in the org -* **association_type**: The type of association the user has +#### Summary Report -> [!NOTE] -> This will either be **team** indicating the user is a member of a team within the org or **repository** indicating the user is an active user in one of the repositories +This report is intended to summarize members that do not have copilot seats and counts to identify there association with users that do have copilot seats in the organization. -> [!IMPORTANT] -> The TIME_PERIOD configuration value noted in the setup section above determines what time period a user is considered to be an active user +A CSV file named `copilot_associations_summary.csv` is generated for this report containing the data. You will see the following values in the report: + +* **member_name**: name of the member that does not have a copilot seat +* **count_teams**: number of teams this member is a member of that has someone with a copilot seat also as a member of that same team +* **count_repos**: number of repositories this member is a contributor in that has someone with a copilot seat that is also a contributor to that repository +* **count_copilot_users**: number of distinct users with a copilot seat that the member is connected to either through a team or a repository +* **total_sum**: count_teams + count_repos + count_copilot, this could be used as a rank and sorted by to determine which members are closer to a higher number of users with copilot seats -* **related_copilot_user_name**: If any other users found within the team or repository have a copilot license they are indicated here as an association to the user that doesn't have a copilot license - > [!NOTE] -> If the value is "Unknown" then this indicates no other active users in the repository or team members were found with a copilot seat for the org +> Use the total_sum column to rank your members, those with a higher number are likely potential opportunities to utilize a copilot seat because of their relationship to users with copilot seats + +#### Detailed Report + +This report is a more detailed report that helps identify the actual associations between a member without a copilot seat and those that do have a copilot seat. + +A CSV file named `copilot_associations_detailed.csv` is generated for this report containing the data. You will see the following values in the report: + +* **member_name**: name of the member that does not have a copilot seat +* **association_type**: either team or repository, the association this member has with a user that has a copilot seat +* **association_name**: either the name of the team or name of the repository that links this member to a user with a copilot seat +* **copilot_user**: the user with a copilot seat identified as being linked to a member > [!NOTE] -> IF the value is "Self" then this indicates this is a user that has a copilot seat assigned from the org any they exist in the repostiory or team. You can filter the report to see what teams and repositories a user with a copilot seat in the org may be currently active in +> Filter the results by copilot_user to see all teams and repositories that user is a member of or is a contributor in ## Settings @@ -95,7 +104,7 @@ ORGANIZATION= ### Optional Settings -Optional settings and their defaut values if not specified +Optional settings and their defaut values if not specified that can be added to the `.env.local` file. ``` # the version of the GitHub API to use @@ -108,6 +117,18 @@ TIME_PERIOD=month # any teams to exclude from the results (comma separated list) EXCLUDE_TEAMS=team1,team2 +# true to make the call to generate data, useful to set to false if data already generated and you just want a report +GENERATE_DATA=true + +# name of the file to give to generated data file or file to use if you already have one +INPUT_FILE_NAME=copilot-associations.json + +# file that will be summary of report +OUTPUT_FILE_NAME=copilot_associations_summary.csv + +# file that will contain all details for report +DETAILED_OUTPUT_FILE_NAME=copilot_associations_detailed.csv + # pretty, json, hidden LOG_TYPE=pretty diff --git a/data/test-data.json b/data/test-data.json new file mode 100644 index 0000000..81a7ad0 --- /dev/null +++ b/data/test-data.json @@ -0,0 +1,144 @@ +{ + "copilot_seats": [ + { "assignee": "user1", "last_activity_at": "2023-01-01" }, + { "assignee": "user2", "last_activity_at": "2023-01-02" }, + { "assignee": "user3", "last_activity_at": "2023-01-03" }, + { "assignee": "user4", "last_activity_at": "2023-01-04" } + ], + "teams": { + "team1": { + "team_name": "team1", + "members": ["member1", "member2", "member3"], + "copilot_users": ["user1"] + }, + "team2": { + "team_name": "team2", + "members": ["member4", "member5"], + "copilot_users": ["user2"] + }, + "team3": { + "team_name": "team3", + "members": ["member3", "member6"], + "copilot_users": ["user3"] + }, + "team4": { + "team_name": "team4", + "members": ["member3", "member5"], + "copilot_users": ["user4"] + }, + "team5": { + "team_name": "team5", + "members": ["member3"], + "copilot_users": ["user1"] + }, + "team6": { + "team_name": "team6", + "members": ["member1", "member3"], + "copilot_users": ["user2"] + }, + "team7": { + "team_name": "team7", + "members": ["member5", "member6"], + "copilot_users": ["user3"] + }, + "team8": { + "team_name": "team8", + "members": ["member2"], + "copilot_users": ["user4"] + }, + "team9": { + "team_name": "team9", + "members": ["member5", "member6"], + "copilot_users": ["user1"] + }, + "team10": { + "team_name": "team10", + "members": ["member3"], + "copilot_users": ["user2"] + }, + "team11": { + "team_name": "team11", + "members": ["member1", "member2"], + "copilot_users": ["user3"] + }, + "team12": { + "team_name": "team12", + "members": ["member5", "member6"], + "copilot_users": ["user4"] + } + }, + "repositories": { + "repo1": { + "repo_owner": "owner1", + "repo_name": "repo1", + "contributors": ["member1", "member2", "member3"], + "associated_copilot_users": ["user1"] + }, + "repo2": { + "repo_owner": "owner2", + "repo_name": "repo2", + "contributors": ["member1", "member5"], + "associated_copilot_users": ["user2"] + }, + "repo3": { + "repo_owner": "owner3", + "repo_name": "repo3", + "contributors": ["member2", "member3"], + "associated_copilot_users": ["user3"] + }, + "repo4": { + "repo_owner": "owner4", + "repo_name": "repo4", + "contributors": ["member1", "member2", "member5"], + "associated_copilot_users": ["user4"] + }, + "repo5": { + "repo_owner": "owner5", + "repo_name": "repo5", + "contributors": ["member6"], + "associated_copilot_users": ["user1"] + }, + "repo6": { + "repo_owner": "owner6", + "repo_name": "repo6", + "contributors": ["member2"], + "associated_copilot_users": ["user2"] + }, + "repo7": { + "repo_owner": "owner7", + "repo_name": "repo7", + "contributors": ["member5", "member6"], + "associated_copilot_users": ["user3"] + }, + "repo8": { + "repo_owner": "owner8", + "repo_name": "repo8", + "contributors": ["member2"], + "associated_copilot_users": ["user4"] + }, + "repo9": { + "repo_owner": "owner9", + "repo_name": "repo9", + "contributors": ["member5", "member6"], + "associated_copilot_users": ["user1"] + }, + "repo10": { + "repo_owner": "owner10", + "repo_name": "repo10", + "contributors": ["member2"], + "associated_copilot_users": ["user2"] + }, + "repo11": { + "repo_owner": "owner11", + "repo_name": "repo11", + "contributors": ["member1", "member2"], + "associated_copilot_users": ["user3"] + }, + "repo12": { + "repo_owner": "owner12", + "repo_name": "repo12", + "contributors": ["member5", "member6"], + "associated_copilot_users": ["user4"] + } + } + } \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index db599c9..168d9d4 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,9 +1,16 @@ import logger from "./shared/app-logger"; import { runCopilotAssociationsReport } from "./report/copilot-associations-report"; +import { AppConfig } from "./shared/app-config"; +import { App } from "octokit"; // for now one report, more to come logger.info("START - Running copilot associations report..."); -runCopilotAssociationsReport().then((output_file) => { +runCopilotAssociationsReport({ + should_generate_data: AppConfig.GENERATE_DATA, + input_file_name: AppConfig.INPUT_FILE_NAME, + output_file_name: AppConfig.OUTPUT_FILE_NAME, + detailed_output_file_name: AppConfig.DETAILED_OUTPUT_FILE_NAME, +}).then((output_file) => { logger.info(`END - Generated copilot associations report ${output_file}`); }); diff --git a/src/report/copilot-associations-report.ts b/src/report/copilot-associations-report.ts index 6909241..dd191f7 100644 --- a/src/report/copilot-associations-report.ts +++ b/src/report/copilot-associations-report.ts @@ -23,10 +23,17 @@ export interface CopilotAssociationsData { }; } -export async function runCopilotAssociationsReport(should_generate_data: boolean = true): Promise { - const input_file_name = 'copilot-associations.json'; - const output_file_name = 'copilot_associations.csv'; - +export async function runCopilotAssociationsReport({ + should_generate_data = true, + input_file_name = 'copilot-associations.json', + output_file_name = 'copilot_associations.csv', + detailed_output_file_name = 'copilot_associations_detailed.csv', +}: { + should_generate_data?: boolean; + input_file_name?: string; + output_file_name?: string; + detailed_output_file_name?: string; +}): Promise { try { if (should_generate_data) { logger.debug("Generating copilot associations data..."); @@ -34,7 +41,7 @@ export async function runCopilotAssociationsReport(should_generate_data: boolean } logger.debug("Running copilot associations report..."); - const output_file = runReport(input_file_name, output_file_name); + const output_file = runReport(input_file_name, output_file_name, detailed_output_file_name); logger.info(`Generated copilot associations report ${output_file}`); return output_file; @@ -69,7 +76,7 @@ async function generateData(file_name: string): Promise { return file; } -function runReport(input_file_name: string, output_file_name: string): string | undefined { +function runReport(input_file_name: string, output_file_name: string, detailed_output_file_name: string): string | undefined { const data = readJsonFile(input_file_name); if (!data) { @@ -80,36 +87,78 @@ function runReport(input_file_name: string, output_file_name: string): string | const associations = getCopilotAssociations(data); const csv_file = writeToCsv(associations, output_file_name); + const detailed_associations = getDetailedCopilotAssociations(data); + writeToCsv(detailed_associations, detailed_output_file_name); + return csv_file; } function getCopilotAssociations(data: CopilotAssociationsData) { - const team_associations = getTeamAssociations(data); - const repository_associations = getRepositoryAssociations(data); + const member_associations = processAssociations(data); - // Create a map for quick lookup of repository associations by member - const repo_associations_map = new Map(repository_associations.map(repo => [repo.member, repo])); + return Object.keys(member_associations).map((member) => { + const associations = member_associations[member]; + const count_teams = associations.teams.size; + const count_repos = associations.repos.size; + const count_copilot_users = associations.copilot_users.size; + const total_sum = count_teams + count_repos + count_copilot_users; - // Create a new array that combines the associations for each member - const associations_by_member = team_associations.map((team_association) => { - const repo_association = repo_associations_map.get(team_association.member); return { - member_name: team_association.member, - //teams: team_association.teams, - count_teams: team_association.count_teams, - //repos: repo_association ? repo_association.repos : [], - count_repos: repo_association ? repo_association.count_repos : 0, + member_name: member, + count_teams, + count_repos, + count_copilot_users, + total_sum, }; }); +} - return associations_by_member; +function getDetailedCopilotAssociations(data: CopilotAssociationsData) { + const member_associations = processAssociations(data); + + const detailed_associations: { member_name: string; association_type: string; association_name: string; copilot_user: string }[] = []; + + for (const member in member_associations) { + const associations = member_associations[member]; + + associations.teams.forEach((team) => { + data.teams[team].copilot_users.forEach((copilot_user) => { + detailed_associations.push({ + member_name: member, + association_type: 'team', + association_name: team, + copilot_user: copilot_user, + }); + }); + }); + + associations.repos.forEach((repo) => { + data.repositories[repo].associated_copilot_users.forEach((copilot_user) => { + detailed_associations.push({ + member_name: member, + association_type: 'repository', + association_name: repo, + copilot_user: copilot_user, + }); + }); + }); + } + + return detailed_associations; } -function getTeamAssociations(data: CopilotAssociationsData): { member: string, teams: string[], count_teams: number }[] { - const member_teams: { [member: string]: Set } = {}; +function processAssociations(data: CopilotAssociationsData) { + const member_associations: { [member: string]: { teams: Set, repos: Set, copilot_users: Set } } = {}; + + processTeamAssociations(data, member_associations); + processRepositoryAssociations(data, member_associations); + return member_associations; +} + +function processTeamAssociations(data: CopilotAssociationsData, member_associations: { [member: string]: { teams: Set, repos: Set, copilot_users: Set } }) { for (const team_name in data.teams) { - if(AppConfig.EXCLUDE_TEAMS.includes(team_name.toLowerCase())) { + if (AppConfig.EXCLUDE_TEAMS.includes(team_name.toLowerCase())) { continue; } @@ -127,24 +176,17 @@ function getTeamAssociations(data: CopilotAssociationsData): { member: string, t continue; } - if (!member_teams[member]) { - member_teams[member] = new Set(); + if (!member_associations[member]) { + member_associations[member] = { teams: new Set(), repos: new Set(), copilot_users: new Set() }; } - member_teams[member].add(team.team_name); + member_associations[member].teams.add(team.team_name); + copilot_users.forEach(user => member_associations[member].copilot_users.add(user)); } } - - return Object.keys(member_teams).map((member) => ({ - member, - teams: Array.from(member_teams[member]), - count_teams: member_teams[member].size, - })); } -function getRepositoryAssociations(data: CopilotAssociationsData): { member: string, repos: string[], count_repos: number }[] { - const member_repos: { [member: string]: Set } = {}; - +function processRepositoryAssociations(data: CopilotAssociationsData, member_associations: { [member: string]: { teams: Set, repos: Set, copilot_users: Set } }) { for (const repo_name in data.repositories) { const repo = data.repositories[repo_name]; const copilot_users = new Set(repo.associated_copilot_users); @@ -160,17 +202,12 @@ function getRepositoryAssociations(data: CopilotAssociationsData): { member: str continue; } - if (!member_repos[contributor]) { - member_repos[contributor] = new Set(); + if (!member_associations[contributor]) { + member_associations[contributor] = { teams: new Set(), repos: new Set(), copilot_users: new Set() }; } - member_repos[contributor].add(repo.repo_name); + member_associations[contributor].repos.add(repo.repo_name); + copilot_users.forEach(user => member_associations[contributor].copilot_users.add(user)); } } - - return Object.keys(member_repos).map((member) => ({ - member, - repos: Array.from(member_repos[member]), - count_repos: member_repos[member].size, - })); -} +} \ No newline at end of file diff --git a/src/shared/app-config.ts b/src/shared/app-config.ts index da66ffe..7763c68 100644 --- a/src/shared/app-config.ts +++ b/src/shared/app-config.ts @@ -9,7 +9,6 @@ export class AppConfig { public static readonly API_VERSION: string = AppConfig.getEnvVar("GITHUB_API_VERSION", "2022-11-28"); public static readonly ORGANIZATION: string = AppConfig.getEnvVar("ORGANIZATION"); public static readonly TIME_PERIOD: TimePeriodType = AppConfig.getEnvVar("TIME_PERIOD", "month") as TimePeriodType; - public static readonly GENERATE_DATA: boolean = AppConfig.getEnvVar("GENERATE_DATA", "true").toLowerCase() === "true"; public static readonly GITHUB_TOKENS_BY_ORG: { [key: string]: string } = AppConfig.getTokensByOrg(AppConfig.getEnvVar("GITHUB_TOKENS_BY_ORG")); public static readonly PER_PAGE: number = parseInt(AppConfig.getEnvVar("PER_PAGE", "100")); public static readonly EXCLUDE_TEAMS: string[] = AppConfig.getEnvVar("EXCLUDE_TEAMS", "") @@ -22,6 +21,12 @@ export class AppConfig { public static readonly LOG_TYPE: string = AppConfig.getEnvVar("LOG_TYPE", "pretty"); public static readonly OUTPUT_LOG_TO_FILE: boolean = AppConfig.getEnvVar("OUTPUT_LOG_TO_FILE", "false").toLowerCase() === "true"; public static readonly HIDE_LOG_POSITION: boolean = AppConfig.getEnvVar("HIDE_LOG_POSITION", "true").toLowerCase() === "true"; + + // report settings + public static readonly GENERATE_DATA: boolean = AppConfig.getEnvVar("GENERATE_DATA", "true").toLowerCase() === "true"; + public static readonly INPUT_FILE_NAME: string = AppConfig.getEnvVar("INPUT_FILE_NAME", "copilot-associations.json"); + public static readonly OUTPUT_FILE_NAME: string = AppConfig.getEnvVar("OUTPUT_FILE_NAME", "copilot_associations_summary.csv"); + public static readonly DETAILED_OUTPUT_FILE_NAME: string = AppConfig.getEnvVar("DETAILED_OUTPUT_FILE_NAME", "copilot_associations_detailed.csv"); private static getEnvVar(name: string, default_value: string = ""): string { const value = process.env[name];