From 3da139d1436734b0edfda8295492b70b0141ad32 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 17:37:06 +0000 Subject: [PATCH 1/5] feat: implement platform-prefixed dispatcher architecture - Add soc dispatcher that reads SOC_PLATFORM_CLI from system_config.sh - Add soc-lib.sh with common utilities (error handling, validation) - Add soc-lib-gh.sh with GitHub CLI specific functions + command implementations - Add soc-gh-{init,precheck,sync,unsync,open,close,invite,disinvite,assign,unassign,log,status,delete} command scripts - Update gh-soc to thin wrapper delegating to soc dispatcher - Add SOC_PLATFORM_CLI='gh' to system_config.sh template in init() - Add docs/adr/001-platform-prefixed-commands.md Agent-Logs-Url: https://github.com/the-soc-org/soc-cli/sessions/58540dc0-2442-4831-9073-4ffbb068a41a Co-authored-by: pierzcham <1788180+pierzcham@users.noreply.github.com> --- docs/adr/001-platform-prefixed-commands.md | 136 + gh-soc | 2862 +------------------- soc | 51 + soc-gh-assign | 21 + soc-gh-close | 16 + soc-gh-delete | 15 + soc-gh-disinvite | 15 + soc-gh-init | 11 + soc-gh-invite | 15 + soc-gh-log | 15 + soc-gh-open | 16 + soc-gh-precheck | 14 + soc-gh-status | 15 + soc-gh-sync | 18 + soc-gh-unassign | 21 + soc-gh-unsync | 18 + soc-lib-gh.sh | 2396 ++++++++++++++++ soc-lib.sh | 372 +++ 18 files changed, 3176 insertions(+), 2851 deletions(-) create mode 100644 docs/adr/001-platform-prefixed-commands.md create mode 100755 soc create mode 100755 soc-gh-assign create mode 100755 soc-gh-close create mode 100755 soc-gh-delete create mode 100755 soc-gh-disinvite create mode 100755 soc-gh-init create mode 100755 soc-gh-invite create mode 100755 soc-gh-log create mode 100755 soc-gh-open create mode 100755 soc-gh-precheck create mode 100755 soc-gh-status create mode 100755 soc-gh-sync create mode 100755 soc-gh-unassign create mode 100755 soc-gh-unsync create mode 100644 soc-lib-gh.sh create mode 100644 soc-lib.sh diff --git a/docs/adr/001-platform-prefixed-commands.md b/docs/adr/001-platform-prefixed-commands.md new file mode 100644 index 0000000..2ef137b --- /dev/null +++ b/docs/adr/001-platform-prefixed-commands.md @@ -0,0 +1,136 @@ +# ADR 001 – Platform-Prefixed Command Scripts + +**Status:** Accepted +**Date:** 2026-05-14 + +--- + +## Context + +SoC CLI automates GitHub organization management tasks (creating repos, teams, +projects, invitations, …). Initially the entire tool was a single monolithic +script (`gh-soc`) that could only target the GitHub platform via the GitHub CLI +(`gh`). + +The project was renamed **soc-cli** to reflect a broader vision: the same +workflow should be usable on other Git hosting platforms (e.g. Gitea via the +`tea` CLI, GitLab via `glab`). This required an architecture that cleanly +separates platform-specific logic from common glue code and from the entry +point. + +--- + +## Decision + +We adopt a **platform-prefixed command-script** model inspired by `git`. + +### 1. `soc` – dispatcher + +A thin `soc` script acts as the entry point for all commands. It reads +`SOC_PLATFORM_CLI` from `system_config.sh` (default: `gh`) and resolves the +concrete executable to run: + +``` +soc open + → reads SOC_PLATFORM_CLI='gh' + → exec soc-gh-open "$@" + +soc open + → reads SOC_PLATFORM_CLI='tea' + → exec soc-tea-open "$@" +``` + +Resolution order: + +1. `soc--` – platform-specific variant (preferred). +2. `soc-` – platform-agnostic fallback (for commands that do not depend + on any external CLI). + +### 2. `soc-lib.sh` – common library + +Contains utilities that are independent of any particular platform: + +- Error handling, visual indicators (`OK`, `YUP`, `WRN`, `ERR`). +- `source_config_files()` – sources the three configuration files. +- Tool-presence checks for `git`, `sed`, `grep`, `jq`, `date`, `touch`. +- All `validate_*` functions. + +### 3. `soc-lib-gh.sh` – GitHub platform library + +Contains everything specific to the GitHub CLI (`gh`): + +- `check_if_gh_installed()`, `check_if_gh_has_required_token_scopes()`. +- All GitHub API / GraphQL helper functions. +- All main command implementations: `init`, `precheck`, `open`, `close`, + `sync`, `unsync`, `log`, `status`, `assign`, `unassign`, `invite`, + `disinvite`, `delete`. + +### 4. `soc-gh-*` – platform-specific command scripts + +One executable file per command, named `soc-gh-`. Each file: + +1. Sources `soc-lib.sh` and `soc-lib-gh.sh`. +2. Sources configuration files (except `soc-gh-init`). +3. Calls `precheck 0` and `check_if_gh_has_required_token_scopes` (except + `soc-gh-init` and `soc-gh-precheck`). +4. Delegates to the corresponding command function. + +Current set: + +| Script | Command | +|--------------------|---------------| +| `soc-gh-init` | `soc init` | +| `soc-gh-precheck` | `soc precheck`| +| `soc-gh-sync` | `soc sync` | +| `soc-gh-unsync` | `soc unsync` | +| `soc-gh-open` | `soc open` | +| `soc-gh-close` | `soc close` | +| `soc-gh-invite` | `soc invite` | +| `soc-gh-disinvite` | `soc disinvite`| +| `soc-gh-assign` | `soc assign` | +| `soc-gh-unassign` | `soc unassign`| +| `soc-gh-log` | `soc log` | +| `soc-gh-status` | `soc status` | +| `soc-gh-delete` | `soc delete` | + +### 5. `gh-soc` – GitHub CLI extension entry point + +The file `gh-soc` is kept so that users can continue to invoke the tool as +`gh soc ` through the GitHub CLI extension mechanism. It is now a +one-line thin wrapper that delegates to the `soc` dispatcher: + +```bash +exec "$(dirname "${BASH_SOURCE[0]}")/soc" "$@" +``` + +### 6. `SOC_PLATFORM_CLI` in `system_config.sh` + +The `init` command generates a `system_config.sh` file that now includes: + +```bash +readonly SOC_PLATFORM_CLI='gh' +``` + +This variable is the single source of truth for which platform a given working +directory targets. Changing its value (e.g. to `'tea'`) switches all `soc` +commands to the corresponding `soc-tea-*` scripts. + +--- + +## Consequences + +### Positive + +- Adding support for a new platform (e.g. Gitea) requires only: + - A new `soc-lib-tea.sh` with Gitea-specific helper functions. + - New `soc-tea-` scripts. + - Setting `SOC_PLATFORM_CLI='tea'` in `system_config.sh`. +- The common library (`soc-lib.sh`) and the dispatcher (`soc`) never need to + change when a new platform is added. +- Each command script is small and easy to read in isolation. + +### Negative / Trade-offs + +- More files to install compared to the original monolith. +- `soc-lib-gh.sh` is still large; further decomposition may be needed as + new platforms are added (to identify which utilities are truly common). diff --git a/gh-soc b/gh-soc index 4e23c69..cc99d34 100755 --- a/gh-soc +++ b/gh-soc @@ -1,2853 +1,13 @@ #!/usr/bin/env bash -# Set up strict error handling to make debugging easier and improve reliability. -# - `+o errexit`: Disable the script's abort-on-first-error feature. -# - `-o nounset`: Aborts the script if an uninitialized variable is used. -# - `-o errtrace`: Allows the ERR trap to be inherited by functions, command substitutions, and subshells. - -set +o errexit -set -o nounset -set -o errtrace - -# Establish a trap for any error that occurs, calling the `error_handler` -# function with the source file and line number as arguments. -trap 'error_handler ${BASH_SOURCE} ${LINENO}' ERR - -# Define visual indicators for the terminal output to enhance readability. -# - `OK`: White Heavy Check Mark (Unicode: U+2705), indicates success. -# - `YUP`: Check Mark (Unicode: U+2713), also indicates confirmation or success. -# - `WRN`: High Voltage Sign (Unicode: U+26A1), used to signal warnings or cautions. -# - `ERR`: Cross Mark (Unicode: U+274C), indicates errors or failure. -OK=✅ -YUP=✓ -WRN=⚡ -ERR=❌ - -# Handles errors by printing the source file and line number where the error -# occurred. Usage: error_handler -error_handler() { - local src="$1" - local line="$2" - - echo "${ERR} Error: in ${src} at line ${line}" >&2 -} - -# Removes a temporary file, if it exists, and prints a confirmation message. -# Usage: remove_tmp_file -remove_tmp_file() { - local file_to_remove="$1" - - if [[ -f "$file_to_remove" ]]; then - rm -f "${file_to_remove}" - echo "${YUP} Temporary file '${file_to_remove}' has been deleted" - fi -} - -# Checks if the GitHub CLI is authenticated with the required token scopes. -# Aborts the script with an error message if required scopes are missing. -# Usage: check_if_gh_has_required_token_scopes -check_if_gh_has_required_token_scopes() { - - local -a token_scopes - local -a missing_scopes - local missing_scopes_csv - - # Fetch the current token scopes using 'gh', extract line with 'grep' and use - # 'sed' to parse the line - token_scopes=$(gh auth status | grep 'Token scopes' | sed 's/.*Token scopes: //') - missing_scopes=() - - # Check for each required scope - for scope in "${GH_CLI_TOKEN_SCOPES[@]}"; do - if [[ "${token_scopes}" != *"${scope}"* ]]; then - missing_scopes+=("${scope}") - fi - done - - # Inform the user about the status - if [[ "${#missing_scopes[@]}" -ne 0 ]]; then - echo "${ERR} Missing required token scopes:" >&2 - for scope in "${missing_scopes[@]}"; do - echo "- ${scope}" >&2 - done - - missing_scopes_csv=$(IFS=,; echo "${missing_scopes[*]}") - echo -n "${WRN} Run 'gh auth refresh -s ${missing_scopes_csv}' " - echo "to update the token." - - exit 1 - fi -} - -check_git_configs() { - - local git_user_name - local git_user_email - - git_user_name=$(git config --global user.name) - git_user_email=$(git config --global user.email) - - # Check if the user has set up the git user name and email - if [[ -z "${git_user_email}" ]]; then - echo -n "${WRN} Git user email is not set. You can set it up with: " - echo "'git config --global user.email \"your_email@example.com\"'." - fi - - if [[ -z "${git_user_name}" ]]; then - echo -n "${ERR} Git user name is not set. For proper functioning of " >&2 - echo -n "'${FUNCNAME[1]}' command, it is required that the git user " >&2 - echo -n "name is set up on the local machine. Please run:" >&2 - echo "'git config --global user.name \"Your Name\"'." >&2 - exit 1 - fi -} - -# Checks if repositories listed in the array in the config file exist. Aborts -# the script with an error message if any repository does not exist. Usage: -# check_if_repos_exist -check_if_repos_exist() { - - if [[ "$#" -lt 2 ]]; then - echo -n "${ERR} Insufficient arguments provided for the " >&2 - echo "'${FUNCNAME[0]}' function." >&2 - exit 1 - fi - - local repos_owner="$1" - shift # Remove the argument - local repos_name="$1" - shift # Remove the argument to treat the rest as an array - local -a repos=("$@") - - # Initialize a flag to track if any repository does not exist - local repo_noexists=0 - - for ((i=0; i<"${#repos[@]}"; i++)); do - # Check if the repository exists - if gh repo view "${repos_owner}/${repos[i]}" &> /dev/null; then - echo -n "${YUP} Repository ${repos_owner}/${repos[i]} " - echo "exists as expected." - else - echo "${ERR} Repository ${repos_owner}/${repos[i]} no exists." >&2 - repo_noexists=1 - fi - done - - # Check the flag and exit with an error if any repository does not exist - if [[ "${repo_noexists}" -eq 1 ]]; then - echo -n "${ERR} For proper functioning of '${FUNCNAME[1]}' command, " >&2 - echo -n "it is required that the repositories listed in the " >&2 - echo -n "'${repos_name}' array in the config file exist " >&2 - echo "on the '${repos_owner}' GitHub account." >&2 - exit 1 - else - echo -n "${OK} The check to see if the '${repos_name}' " - echo "repositories exist has been completed." - fi -} - -# Checks if repositories listed in the array in the config file do not already -# exist. Aborts the script with an warning message if any repository already -# exists. Usage: check_if_repos_noexist -check_if_repos_noexist() { - - local repos_owner="$1" - shift # Remove the argument - local repos_name="$1" - shift # Remove the argument to treat the rest as an array - local -a repos=("$@") - - # Initialize a flag to track if any repository already exists - local repo_exists=0 - - for ((i=0; i<"${#repos[@]}"; i++)); do - # Check if the repository exists - if ! gh repo view "${repos_owner}/${repos[i]}" &> /dev/null; then - echo -n "${YUP} Repository ${repos_owner}/${repos[i]} " - echo "not yet exists as expected." - else - echo "${WRN} Repository ${repos_owner}/${repos[i]} already exists." - repo_exists=1 - fi - done - - # Check the flag and exit with a warning if any repository already exists. The - # presence of repositories may be due to synchronizing repositories beforehand - # or already having own repositories with the same names as the source ones. - if [[ "${repo_exists}" -eq 1 ]]; then - echo -n "${WRN} One or more repositories listed in the '${repos_name}'" - echo -n " array in the config file already exist on the " - echo "'${repos_owner}' GitHub account." - exit 1 - else - echo -n "${OK} The check to see if the '${repos_name}' " - echo "repositories exist has been completed." - fi -} - -# Checks if repositories in source repo owner GitHub account and listed in the -# array in the config file are templates. Attempt to make them templates if they -# are not already. Aborts the script with an error message if any repository is -# not a template. Usage: ensure_repos_as_templates -# -ensure_repos_as_templates() { - - local repos_owner="$1" - shift # Remove the argument - local repos_name="$1" - shift # Remove the argument to treat the rest as an array - local -a repos=("$@") - - # Initialize a flag to track if any repository is not a template - local repo_is_not_template=0 - - for ((i=0; i<"${#repos[@]}"; i++)); do - # Check if the repository is a template - if [[ $(gh api repos/"${repos_owner}/${repos[i]}" --jq '.is_template') == true ]]; then - echo -n "${YUP} Repository ${repos_owner}/${repos[i]} " - echo "is a template as expected." - else - echo -n "${WRN} Repository ${repos_owner}/${repos[i]} is not a template. " - echo "Attempting to make it a template..." - # Attempt to convert the repository to a template - if gh api -X PATCH repos/"${repos_owner}/${repos[i]}" --field is_template=true --silent &> /dev/null; then - echo "${YUP} Successfully transformed into a template." - else - echo "${WRN} Failed to transform into a template." - repo_is_not_template=1 - fi - fi - done - - # Check the flag and exit with a error if any repository is not a template. - if [[ "${repo_is_not_template}" -eq 1 ]]; then - echo -n "${ERR} For proper functioning of '${FUNCNAME[1]}' command, " >&2 - echo -n "it is required that the repositories in '${repos_owner}' " >&2 - echo -n "GitHub account and listed in the '${repos_name}' array " >&2 - echo "in the config file are templates." >&2 - exit 1 - else - echo -n "${OK} The check to see if the '${repos_name}' repositories " - echo "are templates has been completed." - fi -} - -# Forks repositories from SOURCE_REPOS_OWNER to GH_ORG_NAME without cloning -# them. -# Usage: fork_repositories -fork_repositories() { - - for ((i=0; i<"${#SOURCE_REPOS[@]}"; i++)); do - - # Fork the repository to the organization without cloning it locally. - gh repo fork "${SOURCE_REPOS_OWNER}"/"${SOURCE_REPOS[i]}" \ - --org "${GH_ORG_NAME}" \ - --fork-name "${TARGET_REPOS[i]}" \ - --clone=false - - if [[ $? -ne 0 ]]; then - echo -n "${ERR} An error occurred while forking the " >&2 - echo -n "${SOURCE_REPOS_OWNER}/${SOURCE_REPOS[i]} repository to " >&2 - echo "${GH_ORG_NAME}/${TARGET_REPOS[i]}" >&2 - exit 1 - fi - done - echo "${OK} Forking repositories has been completed" -} - -# Create repositories in GH_ORG_NAME from SOURCE_REPOS_OWNER templates. Usage: -# create_private_repos_from_templates -create_private_repos_from_templates() { - - for ((i=0; i<"${#SOURCE_REPOS[@]}"; i++)); do - - # Create a repository in an organization from a template. - gh repo create "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}" \ - --template "${SOURCE_REPOS_OWNER}"/"${SOURCE_REPOS[i]}" \ - --private=true - - if [[ $? -ne 0 ]]; then - echo -n "${ERR} An error occurred while creating the " >&2 - echo -n "${GH_ORG_NAME}/${TARGET_REPOS[i]} repository from " >&2 - echo "the ${SOURCE_REPOS_OWNER}/${SOURCE_REPOS[i]} template" >&2 - exit 1 - fi - done - echo "${OK} Creating repositories has been completed" -} - -# Delete repositories listed in TARGET_REPOS array in user_config.sh file from -# GH_ORG_NAME. Usage: delete_repositories -delete_repositories() { - - for ((i=0; i<"${#TARGET_REPOS[@]}"; i++)); do - - # deleting a repo - gh repo delete "$GH_ORG_NAME"/"${TARGET_REPOS[i]}" --yes - - if [[ $? -ne 0 ]]; then - echo -n "${ERR} An error occurred while deleting the repository" >&2 - echo "$GH_ORG_NAME"/"${TARGET_REPOS[i]}" >&2 - exit 1 - fi - done - - echo "${OK} Deletion of the repositories has been completed." -} - -# Sets specified repositories in GH_ORG_NAME as private and marks them as -# templates. -# Usage: set_repo_as_private_template -set_repo_as_private_template() { - - for ((i=0; i<"${#TARGET_REPOS[@]}"; i++)); do - - gh repo edit "$GH_ORG_NAME"/"${TARGET_REPOS[i]}" \ - --visibility private \ - --template=true - - if [[ $? -ne 0 ]]; then - echo -n "${ERR} An error occurred while setting the " >&2 - echo "$GH_ORG_NAME/${TARGET_REPOS[i]} repository as a private template" >&2 - exit 1 - fi - done - echo "${OK} Setting repositories as private templates has been completed" -} - -check_repo_gh_pages_enabled() { - # Local variable declaration for return value - local has_pages - - has_pages=$(gh api \ - --method GET \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[0]}" \ - --jq '.has_pages') - - if [[ $? -eq 0 ]]; then - echo "${has_pages}" - else - echo -n "${ERR} An error occurred while checking if GitHub Pages " - echo "is enabled for '${GH_ORG_NAME}/${TARGET_REPOS[0]}'." - exit 1 - fi -} - -# Enables GitHub Pages on the first target repository for the specified branch. -# Usage: switch_on_gh_pages -swich_on_gh_pages() { - # Local variable declaration for the return value - local has_pages - - has_pages=$(check_repo_gh_pages_enabled) - if [[ $? -eq 0 ]]; then - if [[ "${has_pages}" == true ]]; then - echo -n "${WRN} GitHub Pages for ${GH_ORG_NAME}/${TARGET_REPOS[0]} " - echo "is already enabled" - return 0 - fi - else - echo "${has_pages}" >&2 - exit 1 - fi - - echo '{"source":{"branch":"'"${GH_REPO_BRANCH}"'","path":"/"}}' | gh api \ - --method POST \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - /repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[0]}"/pages \ - --silent \ - --input - - - if [[ $? -eq 0 ]]; then - echo -n "${OK} GitHub Pages has been enabled on the '${GH_REPO_BRANCH}' " - echo "branch in the ${GH_ORG_NAME}/${TARGET_REPOS[0]} repository." - echo -n "${WRN} Please be patient: the creation process for the GitHub " - echo -n "page has started, but the page itself will be available " - echo "in about 3 minutes." - else - echo -n "${ERR} An error occurred while enabling the '${GH_REPO_BRANCH}' " >&2 - echo "branch in the ${GH_ORG_NAME}/${TARGET_REPOS[0]}" >&2 - exit 1 - fi -} - -# Sets website links for the target repositories in the GitHub organization. -# Usage: set_repos_website -set_repos_website() { - - # Iterate through repositories - for ((i=0; i<"${#TARGET_REPOS[@]}"; i++)); do - - # Setting website link in the target repositories" - gh repo edit "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}" \ - --homepage https://"${GH_ORG_NAME}".github.io/"${TARGET_REPOS[0]}"/ - - if [[ $? -ne 0 ]]; then - echo -n "${ERR} An error occurred while setting a website link in the " >&2 - echo "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}} repository" >&2 - exit 1 - fi - done - echo "$OK Setting the website links has been completed" -} - -# Sets descriptions for the target repositories in the GitHub organization. -# Usage: set_repos_descriptions -set_repos_descriptions() { - - # Iterate through repositories - for ((i=0; i<"${#TARGET_REPOS[@]}"; i++)); do - - # Setting website link in the target repositories" - gh repo edit "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}" \ - --description "${TARGET_REPO_DESCRIPTION}" - - if [[ $? -ne 0 ]]; then - echo -n "${ERR} An error occurred while setting a website link in the " >&2 - echo "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}} repository" >&2 - exit 1 - fi - done - echo "$OK Setting the website links has been completed" -} - -# Retrieves the SHA for a file in the first target repository. -get_file_sha() { - - # Check if the correct number of non-empty arguments is passed - if [[ "$#" -ne 1 || -z "$1" ]]; then - echo "${ERR} Invalid number of arguments or empty argument." >&2 - echo "${WRN} Usage: ${FUNCNAME[0]} " - exit 1 - fi - - # Local variable declaration for the return value - local sha - - # Get the file SHA - sha=$(gh api \ - --method GET \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[0]}"/contents/"${source_file}" \ - --jq '.sha') - - if [[ $? -eq 0 ]]; then - if [[ -n "${sha}" ]]; then - echo "${sha}" - else - echo "${ERR} SHA for the '${source_file}' is emapty" - exit 1 - fi - else - echo "${ERR} An error occurred while obtaining SHA for the '${source_file}'" - exit 1 - fi -} - -# Downloads the contents of a specified file from a repository to a temporary -# file. -download_repo_file_contents_to_tmp_file() { - - # Check if the correct number of non-empty arguments is passed - if [[ "$#" -ne 2 || -z "$1" || -z "$2" ]]; then - echo "${ERR} Invalid number of arguments or empty argument." >&2 - echo "${WRN} Usage: ${FUNCNAME[0]} " - exit 1 - fi - - local source_file="$1" - local tmp_file="$2" - - gh api \ - --method GET \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[0]}"/contents/"${source_file}" \ - --jq '.content' | base64 --decode > "${tmp_file}" - - # Check whether the operation was successful - if [[ $? -eq 0 ]]; then - echo -n "${YUP} The content of the '${source_file}' has been written " - echo "to the '${tmp_file}'" - else - echo -n "${ERR} An error occurred while downloading '${source_file}' " >&2 - echo "or writing its content to the '${tmp_file}'" >&2 - exit 1 - fi -} - -# Updates the contents of a temporary file by replacing specific text. -# It checks for the presence of SOURCE_REPOS_OWNER and then replaces it with GH_ORG_NAME. -# Additionally, it iterates through repositories to replace other specified text. -update_tmp_file() { - - # Check if the correct number of non-empty arguments is passed - if [[ "$#" -ne 1 || -z "$1" ]]; then - echo "${ERR} Invalid number of arguments or empty argument." >&2 - echo "${WRN} Usage: ${FUNCNAME[0]} " - exit 1 - fi - - local tmp_file="$1" - - if grep -q "${SOURCE_REPOS_OWNER}" "${tmp_file}"; then - # Perform the replacement in the temporary file - sed -i "s/${SOURCE_REPOS_OWNER}/${GH_ORG_NAME}/g" "${tmp_file}" - - if [[ $? -ne 0 ]]; then - echo -n "${ERR} An error occurred while updating the " >&2 - echo "temporary file: ${tmp_file}" >&2 - exit 1 - fi - else - echo -n "${WRN} Text to be replaced '${SOURCE_REPOS_OWNER}' " - echo "was not found in the file: '${tmp_file}'." - fi - - - # Iterate through repositories - for ((i=1; i<"${#SOURCE_REPOS[@]}"; i++)); do - - local word_to_replace="${SOURCE_REPOS[i]}" - local new_word="${TARGET_REPOS[i]}" - - if grep -q "${word_to_replace}" "${tmp_file}"; then - # Perform the replacement in the temporary file - sed -i "s/${word_to_replace}/${new_word}/g" "${tmp_file}" - - if [[ $? -ne 0 ]]; then - echo -n "${ERR} An error occurred while updating the " >&2 - echo "temporary file: '${tmp_file}'" >&2 - exit 1 - fi - else - echo -n "${WRN} Text to be replaced '${word_to_replace}' " - echo "was not found in the file: '${tmp_file}'." - fi - done -} - -# Updates the contents of a file in a repository by uploading a new version. -# This function uses a temporary file containing the new content, the SHA of the -# existing file, and the file path to update the file in the repository. -update_repo_file_contents() { - - # Check if the correct number of non-empty arguments is passed - if [[ "$#" -ne 3 || -z "$1" || -z "$2" || -z "$3" ]]; then - echo "${ERR} Invalid number of arguments or empty argument." >&2 - echo "${WRN} Usage: ${FUNCNAME[0]} " - exit 1 - fi - - local tmp_file="$1" - local file_sha="$2" - local source_file="$3" - - gh api \ - --method PUT \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[0]}"/contents/"${source_file}" \ - --raw-field "message=update ${source_file}" \ - --raw-field "content=$(base64 < ${tmp_file})" \ - --raw-field "sha=${file_sha}" \ - --silent - - # Check whether the operation was successful - if [[ $? -eq 0 ]]; then - echo "${YUP} The ${source_file} has been updated" - else - echo "${ERR} An error occurred while updating the '${source_file}'" >&2 - exit 1 - fi -} - -# Updates repository files without cloning the entire repository. -# This function iterates through a list of files, performing the following steps -# for each: -# 1. Creates a temporary file to work with the file's content. -# 2. Retrieves the SHA of the current file in the repository to allow for its update. -# 3. Downloads the file content from the repository to the temporary file. -# 4. Applies changes to the content within the temporary file. -# 5. Uploads the updated content back to the repository, replacing the old file. -# Usage: update_repo_without_cloning -update_repo_without_cloning() { - - local file_sha - local tmp_file - - # Generate two temporary files and get sha of each source file - for source_file in "${SOURCE_MAIN_REPO_INFO_FILES[@]}"; do - - # Create a temporary file - tmp_file="$(mktemp)" - if [[ $? -ne 0 ]]; then - echo "${ERR} Unable to create a temporary file" >&2 - exit 1 - else - echo "${YUP} Temporary file '${tmp_file}' has been created" - fi - - # Remove the temporary file if the script exits unexpectedly. This ensures that even if the - # script fails or is interrupted, the temporary file does not remain on disk. - # NOTE: ShellCheck may warn about SC2064 here (variable expansion at trap assignment), but this - # is intentional. - trap "remove_tmp_file ${tmp_file}" EXIT - - # Get the file SHA - file_sha=$(get_file_sha "${source_file}") - if [[ $? -eq 0 ]]; then - echo "${YUP} The SHA for the '${source_file}' file has been obtained" - else - echo "${file_sha}" >&2 - exit 1 - fi - - # Download the file contents to the temporary file - download_repo_file_contents_to_tmp_file "${source_file}" "${tmp_file}" - # Update the temporary file - update_tmp_file "${tmp_file}" - # Update the repository file contents - update_repo_file_contents "${tmp_file}" "${file_sha}" "${source_file}" - - # Remove the temporary file - if [[ -f "${tmp_file}" ]]; then - rm -f "${tmp_file}" - echo "${YUP} Temporary file ${tmp_file} has been deleted" - echo - fi - done - echo "${OK} The repo '${TARGET_REPOS[0]}' has been updated" -} - -# Updates the default repository permission setting for the GitHub organization. -# This function sets the default permission that new repositories will have when -# created within the organization. It uses the GitHub API to patch the -# organization's settings. -# Usage: update_org_default_repo_permission -update_org_default_repo_permission() { - - local repo_permission="${GH_ORG_DEFAULT_REPOSITORY_PERMISSION}" - - # Update the organization settings - gh api \ - --method PATCH \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - /orgs/"${GH_ORG_NAME}" \ - --raw-field default_repository_permission="${repo_permission}" \ - --silent - - ## Check whether the operation was successful - if [[ $? -eq 0 ]]; then - echo -n "${OK} The '${GH_ORG_NAME}' organization default repo permission " - echo "setting has been updated with '${repo_permission}' value" - else - echo -n "${ERR} An error occurred while updating the '${GH_ORG_NAME}' " >&2 - echo -n "organization default repo permission setting with " >&2 - echo "'${repo_permission}' value" >&2 - exit 1 - fi -} - -# Updates the organization setting to allow or disallow members from forking -# private repositories. This function configures whether members of the GitHub -# organization can fork private repositories. It performs a PATCH request to -# the GitHub API to update the organization's settings accordingly. -# Usage: update_org_members_can_fork_private_repo -update_org_members_can_fork_private_repo() { - - local fork_private="${GH_ORG_MEMBERS_CAN_FORK_PRIVATE_REPOSITORIES}" - - # Update the organization settings - gh api \ - --method PATCH \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - /orgs/"${GH_ORG_NAME}" \ - --field members_can_fork_private_repositories="${fork_private}" \ - --silent - - ## Check whether the operation was successful - if [[ $? -eq 0 ]]; then - echo -n "${OK} The ${GH_ORG_NAME} organization 'members can fork private repo'" - echo "setting has been updated with '${fork_private}' value" - else - echo -n "${ERR} An error occurred while updating the '${GH_ORG_NAME}' " >&2 - echo -n "organization 'members can fork private repo' setting with " >&2 - echo "'${fork_private}' value" >&2 - exit 1 - fi -} - -# Creates a new team within the GitHub organization. This function uses the -# GitHub API to create a team with the specified details. It requires the -# organization name, team name, description, notification setting, and privacy -# level. -# Usage: create_team -create_team() { - # Local variable declaration for the return value - local team_id - - # Retrieve the team ID - team_id=$(get_team_id) - - if [[ $? -eq 0 ]]; then - if [[ -n "${team_id}" ]]; then - echo -n "${WRN} The '${GH_TEAM_NAME}' team already exists " - echo "in the '${GH_ORG_NAME}' organization." - return 0 - fi - else - echo "${team_id}" >&2 - exit 1 - fi - - # Creating the team - gh api \ - --method POST \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - /orgs/"${GH_ORG_NAME}"/teams \ - --raw-field name="${GH_TEAM_NAME}" \ - --raw-field description="${GH_TEAM_DESCRIPTION}" \ - --raw-field notification_setting="${GH_TEAM_NOTIFICATIONS}" \ - --raw-field privacy="${GH_TEAM_PRIVACY}" \ - --silent - - # Checking if the operation was successful - if [[ $? -eq 0 ]]; then - echo -n "${OK} The team '${GH_TEAM_NAME}' has been created " - echo "in the organization '${GH_ORG_NAME}'." - else - echo "${ERR} An error occurred while creating the team '${GH_TEAM_NAME}'." >&2 - exit 1 - fi -} - -# Creates a new project in the GitHub organization from a specified template. -# It's intended to copy a project from another source into the target -# organization under a new title. Usage: create_project_from_template -create_project_from_template() { - - local project_number - - project_number=$(get_project_number) - if [[ $? -eq 0 ]]; then - if [[ -n "${project_number}" ]]; then - echo -n "${WRN} Project with the '${TARGET_PROJECT_TITLE}' title " - echo "already exists in the '${GH_ORG_NAME}' organization." - return 0 - fi - else - echo "${project_number}" >&2 - exit 1 - fi - - gh project copy "${SOURCE_PROJECT_NUMBER}" \ - --source-owner "${SOURCE_PROJECT_OWNER}" \ - --target-owner "${GH_ORG_NAME}" \ - --title "${TARGET_PROJECT_TITLE}" - - if [[ $? -eq 0 ]]; then - echo "${OK} The project '${TARGET_PROJECT_TITLE}' has been created." - else - echo -n "${ERR} An error occurred while copying the project " >&2 - echo "from ${SOURCE_PROJECT_OWNER} to '${GH_ORG_NAME}'." >&2 - exit 1 - fi -} - -# Clones the specified repositories from the GitHub organization to the local -# machine. Iterates through the list of target repositories and clones each one -# using the 'gh repo clone' command. -# Usage: clone_repositories -clone_repositories() { - # Iterate through repositories - for ((i=0; i<"${#TARGET_REPOS[@]}"; i++)); do - - # Check if the repository directory already exists - if [[ -d "${TARGET_REPOS[i]}" ]]; then - echo -n "${WRN} Skipping cloning ${TARGET_REPOS[i]} because repository " - echo "directory already exists." - continue # Skip to the next repository - fi - - gh repo clone "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}" - - if [[ $? -ne 0 ]]; then - echo -n "${ERR} An error occurred while cloning the " >&2 - echo "${GH_ORG_NAME}/${TARGET_REPOS[i]} repository" >&2 - exit 1 - fi - done - echo "${OK} Cloning repositories has been completed" -} - -# Sets a specific team as the CODEOWNERS for all targeted repositories. This -# function iterates through the list of repositories, excluding the first one, -# and updates the CODEOWNERS file to reflect the designated team. It checks if -# the CODEOWNERS file is writable before attempting to update it. -# Usage: set_team_as_codeowners -set_team_as_codeowners() { - - # Iterate through repositories, starting from the second one because the first - # one does not have a 'CODEOWNERS' file - for ((i=1; i<"${#TARGET_REPOS[@]}"; i++)); do - - local file_path="${TARGET_REPOS[i]}/.github/CODEOWNERS" - - if [[ ! -w "${file_path}" ]]; then - # Because we have 'exit 1' here, the trap won't be activated. - echo -n "${ERR} The file ${file_path} does not exist " >&2 - echo "or is not writable." >&2 - exit 1 - fi - - # Overwrite the first line in the CODEOWNERS file - echo "* @${GH_ORG_NAME}/${GH_TEAM_NAME}" > "${file_path}" - - echo -n "${YUP} The CODEOWNERS file in '${TARGET_REPOS[i]}' " - echo "has been updated to ${GH_ORG_NAME}/${GH_TEAM_NAME}." - done - echo "${OK} The configuration of the 'CODEOWNERS' files has been completed." -} - -# Commits changes in local repositories and pushes them to their remote -# counterparts. This function iterates through the repositories, excluding the -# first one, to commit any changes made, specifically after updating CODEOWNERS -# or similar files, and then pushes these changes to the remote repository. It -# uses a custom 'git_with_dir' function to specify the git directory and work -# tree for each operation. Usage: commit_changes_and_push_to_remote_repos -commit_changes_and_push_to_remote_repos() { - # Set the commit message - local commit_message="Assign ${GH_TEAM_NAME} as the code owners." - - # Function to execute git commands with specified git dir and work tree - git_with_dir() { - git --git-dir="$1/.git" --work-tree="$1" "${@:2}" - } - - # Iterate through repositories, starting from the second one because in the first - # one we do not change any file. - for ((i=1; i<"${#TARGET_REPOS[@]}"; i++)); do - local target_repo="${TARGET_REPOS[i]}" - - if [[ ! -d "${target_repo}" ]]; then - # Because we have 'exit 1' here, the trap won't be activated. - echo -n "${ERR} The directory ${target_repo} does not exist." >&2 - echo "Clone repositories first." >&2 - exit 1 - fi - - # Add changes to the staging area - git_with_dir "${target_repo}" add . - - # Check if there are changes to commit - if git_with_dir "${target_repo}" diff --staged --quiet; then - echo -n "${WRN} The ${target_repo} repository doesn't have " - echo "any changes to commit." - else - # Commit the changes - git_with_dir "${target_repo}" commit -m "${commit_message}" - - # Push the changes to the remote repository - git_with_dir "${target_repo}" push origin "${GH_REPO_BRANCH}" - - echo -n "${YUP} Changes made in the ${target_repo} directory " - echo "have been pushed to the remote repository." - fi - done - echo "${OK} Updating remote repositories has been completed." -} - -# Applies branch protection rules to lock branches for specified repositories -# within a GitHub organization. It iterates over the repositories, starting -# from the second, to apply a "lock branch" rule, which prevents direct pushes -# to the specified branches, requiring pull requests for changes. -# Usage: lock_branch -lock_branch() { - # Iterate through the repositories, starting from the second one because the - # first one does not contain assignments for students and so does not need the - # branch to be locked - for ((i=1; i<"${#TARGET_REPOS[@]}"; i++)); do - - # Branch protection rule setup: lock branch - gh api \ - --method PUT \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[i]}"/branches/"${GH_REPO_BRANCH}"/protection \ - --field required_status_checks='null' \ - --field enforce_admins='null' \ - --field required_pull_request_reviews='null' \ - --field restrictions='null' \ - --field lock_branch='true' \ - --silent - - # Check whether the operation was completed - if [[ $? -eq 0 ]]; then - echo -n "${YUP} The branch protection rule 'lock branch' " - echo -n "for the '${GH_REPO_BRANCH}' branch on the repository " - echo "${GH_ORG_NAME}/${TARGET_REPOS[i]} has been set." - else - echo "${ERR} An error occurred while setting branch protection rule." >&2 - exit 1 - fi - done - echo "${OK} Branch protection rule 'lock branch ' setup completed." -} - -# Enforces a branch protection rule requiring conversation resolution before -# merging pull requests. This function iterates through the repositories, -# excluding the first, to apply this specific rule. It ensures all discussions -# are resolved before allowing merges, enhancing collaboration quality. -# Usage: require_conversation_resolution_before_merging -require_conversation_resolution_before_merging() { - # Iterate through the repositories, starting from the second one because the - # first one does not contain assignments for students and so does not need the - # branch rule to be set to 'require conversation resolution before merging'. - for ((i=1; i<"${#TARGET_REPOS[@]}"; i++)); do - - # Branch protection rule setup: Require conversation resolution before merging - gh api \ - --method PUT \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[i]}"/branches/"${GH_REPO_BRANCH}"/protection \ - --field required_status_checks='null' \ - --field enforce_admins='null' \ - --field required_pull_request_reviews='null' \ - --field restrictions='null' \ - --field required_conversation_resolution='true' \ - --silent - - # Check whether the operation was successful - if [[ $? -eq 0 ]]; then - echo -n "${YUP} The branch protection rule " - echo -n "'Require conversation resolution before merging' for the " - echo -n "'${GH_REPO_BRANCH}' branch on the repository " - echo "${GH_ORG_NAME}/${TARGET_REPOS[i]} has been set." - else - echo -n "${ERR} An error occurred while setting up " >&2 - echo "the branch protection rule." >&2 - exit 1 - fi - done - echo -n "${OK} Branch protection rule " - echo "'Require conversation resolution before merging' setup completed." -} - - -# Retrieves the unique ID of a project by its title within a GitHub -# organization. This ID is essential for operations that modify project -# settings or link the project to other entities. The function lists all -# projects under the specified organization and filters by title to find the -# correct project ID. Usage: get_project_id -get_project_id() { - # Local variable declaration for the return value - local project_id - - # List projects under a specific organization and extract the project ID using - # jq based on the title. - project_id=$(gh project list \ - --owner "${GH_ORG_NAME}" \ - --format 'json' \ - --jq ".projects[] | select(.title == \"${TARGET_PROJECT_TITLE}\") | .id") - - if [[ $? -eq 0 ]]; then - echo "${project_id}" - else - echo -n "${ERR} An error occurred while obtaining the ID of the " - echo -n "'${TARGET_PROJECT_TITLE}' project from the '${GH_ORG_NAME}' " - echo "organization." - exit 1 - fi -} - -# Retrieves the unique ID of a team by its name within a GitHub organization. -# This ID is used for assigning permissions or linking the team to projects and -# repositories. The function uses a GraphQL query to fetch the team ID based on -# the provided team name. -# Usage: get_team_id -get_team_id() { - # Local variable declaration for the return value - local team_id - - # Define a GraphQL query to retrieve the team ID from the GitHub API. - local query=" - { - organization(login: \"${GH_ORG_NAME}\") { - team(slug: \"${GH_TEAM_NAME}\") { - id - } - } - } - " - - # Execute the GraphQL query and extract the team ID using - # jq. - team_id=$(gh api graphql \ - --raw-field query="${query}" \ - --jq '.data.organization.team.id') - - if [[ $? -eq 0 ]]; then - echo "${team_id}" - else - echo -n "${ERR} An error occurred while obtaining the ID of the " - echo "'${GH_TEAM_NAME}' team from the '${GH_ORG_NAME}' organization" - exit 1 - fi -} - -# Links a specific project to a specific team within a GitHub organization. -# This association allows for refined permission management and project access -# control. The function retrieves both the project ID and team ID, then uses a -# GraphQL mutation to create the link. Usage: link_project_to_team -link_project_to_team() { - # Local variable declarations - local project_id - local team_id - - # Retrieve the project ID. - project_id=$(get_project_id) - - if [[ $? -eq 0 ]]; then - if [[ -n "${project_id}" ]]; then - echo -n "${YUP} Obtaining the ID of the '${TARGET_PROJECT_TITLE}' project" - echo " has been completed" - else - echo -n "${WRN} The obtained ID of the '${TARGET_PROJECT_TITLE}' project " - echo -n "is empty: the '${GH_ORG_NAME}' organization has no such project. " - echo -n "Check the 'user_config' file to see if you have entered " - echo "the 'GH_ORG_NAME' and 'TARGET_PROJECT_TITLE' correctly." - exit 1 - fi - else - echo "${project_id}" >&2 - exit 1 - fi - - # Retrieve the team ID - team_id=$(get_team_id) - - if [[ $? -eq 0 ]]; then - if [[ -n "${team_id}" ]]; then - echo -n "${YUP} Obtaining the ID of the '${GH_TEAM_NAME}' team" - echo " has been completed" - else - echo -n "${ERR} The obtained ID of the '${GH_TEAM_NAME}' team " >&2 - echo -n "is empty: the '${GH_ORG_NAME}' organization has no such team. " >&2 - echo -n "Check the 'user_config' file to see if you have entered " >&2 - echo "the 'GH_ORG_NAME' and 'GH_TEAM_NAME' correctly." >&2 - exit 1 - fi - else - echo "${team_id}" >&2 - exit 1 - fi - - # Define a GraphQL mutation to link the project to the team. - local query=" - mutation { - linkProjectV2ToTeam(input: { - teamId: \"${team_id}\", - projectId: \"${project_id}\", - }) { - clientMutationId - } - } - " - - # Execute the GraphQL mutation using the GitHub CLI. - gh api graphql \ - --raw-field query="${query}" \ - --silent - - # Check the exit status of the last command to verify if the link operation - # was successful. - if [[ $? -eq 0 ]]; then - echo -n "${OK} The '${TARGET_PROJECT_TITLE}' project has been linked " - echo "to the '${GH_TEAM_NAME}' team" - else - echo -n "${ERR} An error occurred while linking the " >&2 - echo "'${TARGET_PROJECT_TITLE}' project to the '${GH_TEAM_NAME}' team" >&2 - exit 1 - fi -} - -# Fetches the unique ID of a GitHub user based on their login. This function is -# crucial for operations that require user identification, such as assigning -# roles or permissions. It verifies the provided argument and utilizes a -# GraphQL query to retrieve the user ID. -get_user_id() { - # Check if the correct number of non-empty arguments is passed - if [[ "$#" -ne 1 || -z "$1" ]]; then - echo "${ERR} Invalid number of arguments or empty argument." >&2 - echo "${WRN} Usage: ${FUNCNAME[0]} " - exit 1 - fi - - local gh_login="$1" - - # Local variable declaration for the return value - local user_id - - local query=" - { - user(login: \"${gh_login}\") { - id - } - } - " - - user_id=$(gh api graphql \ - --raw-field query="${query}" \ - --jq '.data.user.id') - - if [[ $? -eq 0 ]]; then - if [[ -n "${user_id}" ]]; then - echo "${user_id}" - else - echo -n "${ERR} The obtained ID of the '${gh_login}' user " - echo -n "is empty: the '${gh_login}' user does not exist. " - echo -n "Check the 'user_config' file to see if you have entered " - echo "the 'GH_TEAM_MEMBERS_EXCLUDED_FROM_REVIEWING' correctly." - exit 1 - fi - else - echo -n "${ERR} An error occurred while obtaining the ID " - echo "of the '${gh_login}' user." - exit 1 - fi -} - -# Enables automatic review assignment for a specified team within a GitHub -# organization. Iterates through GitHub user logins provided to exclude specific -# team members from automatic review assignments. Utilizes GraphQL to update -# team review assignment settings, including excluded members, algorithm, and -# notification preferences. -# Usage: enable_team_review_assignment -enable_team_review_assignment() { - # Local variable declaration - local github_logins=("${GH_TEAM_MEMBERS_EXCLUDED_FROM_REVIEWING[@]}") - local team_id - local -a user_ids # the array of GitHub user IDs - local fetched_user_id - local users_id_string - - team_id=$(get_team_id) - - if [[ $? -eq 0 ]]; then - if [[ -n "${team_id}" ]]; then - echo -n "${YUP} Obtaining the ID of the '${GH_TEAM_NAME}' team" - echo " has been completed" - else - echo -n "${ERR} The obtained ID of the '${GH_TEAM_NAME}' team " >&2 - echo -n "is empty: the '${GH_ORG_NAME}' organization has no such team. " >&2 - echo -n "Check the 'user_config' file to see if you have entered " >&2 - echo "the 'GH_ORG_NAME' and 'GH_TEAM_NAME' correctly." >&2 - exit 1 - fi - else - echo "${team_id}" >&2 - exit 1 - fi - - # Loop through all logins and execute a GraphQL query for each of them - for gh_login in "${github_logins[@]}"; do - fetched_user_id=$(get_user_id "${gh_login}") - if [[ $? -eq 0 ]]; then - user_ids+=("\"$fetched_user_id\"") # Adding ID as a string to the array - echo -n "${YUP} Obtaining the ID of the '${gh_login}' " - echo "user has been completed" - else - echo "${fetched_user_id}" >&2 - exit 1 - fi - done - - # TODO: - # add checking that the user belongs to the organization - # END: - - # Add comma between user IDs - users_id_string=$(IFS=,; echo "${user_ids[*]}") - - # Define the GraphQL mutation for updating team review assignment settings. - local query=" - mutation { - updateTeamReviewAssignment(input: { - algorithm: ${GH_TEAM_REVIEW_ASSIGNMENT_ALGORITHM}, - enabled: true, - excludedTeamMemberIds: [${users_id_string}], - id: \"${team_id}\", - notifyTeam: ${GH_TEAM_NOTIFY}, - teamMemberCount: ${GH_TEAM_MEMBER_COUNT}, - }) { - clientMutationId - } - } - " - - # Execute the GraphQL mutation - gh api graphql \ - --header "Accept: application/vnd.github.stone-crop-preview+json" \ - --raw-field query="${query}" \ - --silent - - # Check whether the operation was successful - if [[ $? -eq 0 ]]; then - echo -n "${OK} The review assignment has benn enabled " - echo "for the '${GH_TEAM_NAME}' team" - else - echo -n "${ERR} An error occurred while enabling the review " >&2 - echo "assignment for the '${GH_TEAM_NAME}' team" >&2 - exit 1 - fi -} - -# Retrieves the numeric ID of a team within a GitHub organization. This ID is -# essential for certain API calls that require team identification. The script -# uses the GitHub CLI to query the GitHub API and extract the team's numeric ID. -# Usage: get_team_number -get_team_number() { - # Local variable declaration for the return value - local team_number - - # Retrieve the team number - team_number=$(gh api \ - --method GET \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}" \ - --jq '.id') - - if [[ $? -eq 0 ]]; then - echo "${team_number}" - else - echo -n "${ERR} An error occurred while obtaining the number of the " - echo "'${GH_TEAM_NAME}' team from the '${GH_ORG_NAME}' organization." - exit 1 - fi -} - -# Invites users to a specified team within a GitHub organization based on email -# addresses. This script reads email addresses from a file named 'emails.txt', -# constructs and sends invitation requests for each. -# Usage: invite_to_organization_team -invite_to_organization_team() { - # Check if the file with emails exists and is readable - if [[ ! -r 'emails.txt' ]]; then - echo "${ERR} The file 'emails.txt' is not readable or does not exist." >&2 - exit 1 - fi - - # Local variable declaration - local team_number - local response - local http_status - local email - - # Get the team ID - team_number=$(get_team_number) - if [[ $? -eq 0 ]]; then - echo -n "${YUP} Obtaining the ID of the '${GH_TEAM_NAME}' team" - echo " has been completed" - else - echo "${team_number}" >&2 - exit 1 - fi - - # Reading from a file - while IFS= read -r email; do - - # Sending an invitation - response=$(gh api \ - --method POST \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - /orgs/"${GH_ORG_NAME}"/invitations \ - --raw-field email="${email}" \ - --raw-field role="${GH_ORG_ROLE}" \ - --field "team_ids[]=${team_number}" \ - --include) - - # Check whether the operation was successful - if [[ $? -eq 0 ]]; then - echo "${YUP} Invitation to join the '${GH_ORG_NAME}' sent to: ${email}." - else - # Extract the HTTP status code from the first line of the 'response' and and - # look for '422' - http_status=$(echo "${response}" | grep --max-count=1 -o '422') - - if [[ "${http_status}" -eq 422 ]]; then - echo -n "${WRN} The invitation to '${email}' has not been sent. The email " - echo -n "address is not valid, or the variable 'GH_ORG_ROLE' from " - echo -n "'system_config.sh' is not valid, or the user with this email may " - echo -n "already be a member of the '${GH_ORG_NAME}' organization. If the " - echo -n "latter, add the user to the '${GH_TEAM_NAME}' team manually or send " - echo -n "the link https://github.com/orgs/${GH_ORG_NAME}/teams/${GH_TEAM_NAME}" - echo " to the user so he/she can request to join the team." - else - echo -n "${ERR} Failed to send the '${GH_ORG_NAME}' invitation " >&2 - echo "to: ${email}" >&2 - exit 1 - fi - fi - done < 'emails.txt' -} - -get_invitation_id_by_email() { - # Check if the correct number of non-empty arguments is passed - if [[ "$#" -ne 1 || -z "$1" ]]; then - echo "${ERR} Invalid number of arguments or empty argument." >&2 - echo "${WRN} Usage: ${FUNCNAME[0]} " - exit 1 - fi - - # local variable declaration - local email="$1" - local invitation_id - - # Fetch all pending invitations and find the one matching the email - invitation_id=$(gh api \ - --method GET \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - /orgs/"${GH_ORG_NAME}"/invitations \ - --jq ".[] | select(.email==\"${email}\") | .id") - - if [[ $? -eq 0 ]]; then - echo "${invitation_id}" - else - echo -n "${ERR} An error occurred while obtaining the id of invitation to " - echo "'${GH_ORG_NAME}' organization." - exit 1 - fi -} - -cancel_invitations_to_organization() { - # Check if the file with emails exists and is readable - if [[ ! -r 'emails.txt' ]]; then - echo "${ERR} The file 'emails.txt' is not readable or does not exist." >&2 - exit 1 - fi - - # Local variable declaration - local email - local invitation_id - - # Reading from a file - while IFS= read -r email; do - # Fetch all pending invitations and find the one matching our email - invitation_id=$(get_invitation_id_by_email "${email}") - if [[ $? -eq 0 ]]; then - if [[ -z "${invitation_id}" ]]; then - echo "${WRN} No pending invitation found for: ${email}." - continue - fi - else - echo "${invitation_id}" >&2 - exit 1 - fi - - # Cancel the invitation - gh api \ - --method DELETE \ - /orgs/"${GH_ORG_NAME}"/invitations/"${invitation_id}" \ - --silent - - if [[ $? -eq 0 ]]; then - echo "${YUP} Cancelled the invitation for: '${email}'." - else - echo "${ERR} Failed to cancel the invitation for: '${email}'." >&2 - fi - done < 'emails.txt' -} - -# Retrieves the project ID for a specified project title within a GitHub -# organization. -# Usage: get_project_number -get_project_number() { - # Local variable declaration for the return value - local project_number - - # List projects under a specific organization and extract the project ID using - # jq based on the title. - project_number=$(gh project list \ - --owner "${GH_ORG_NAME}" \ - --format 'json' \ - --jq ".projects[] | select(.title == \"${TARGET_PROJECT_TITLE}\") | .number") - - if [[ $? -eq 0 ]]; then - echo "${project_number}" - else - echo -n "${ERR} An error occurred while obtaining the number of the " - echo -n "'${TARGET_PROJECT_TITLE}' project from the '${GH_ORG_NAME}' " - echo "organization." - exit 1 - fi -} - -# Fetches and saves the project's data as a JSON file, named with the project -# title and current timestamp. -# Usage: get_project_data_as_json -get_project_data_as_json() { - # Local variable declaration - local project_number - - timestamp=$(date "+%F-%H-%M-%S") - filename="logs/${TARGET_PROJECT_TITLE}-${timestamp}.json" - - # Check if the file can be created - if [[ -f "${filename}" ]]; then - echo "${ERR} File '${filename}' already exists." >&2 - exit 1 - elif ! touch "${filename}" &>/dev/null; then - echo -n "${ERR} File '${filename}' cannot be created. " >&2 - echo "Check permissions or disk space." >&2 - exit 1 - fi - - project_number=$(get_project_number) - if [[ $? -eq 0 ]]; then - if [[ -n "${project_number}" ]]; then - echo -n "${YUP} Obtaining the number of the '${TARGET_PROJECT_TITLE}' " - echo "project has been completed" - else - echo -n "${WRN} The '${TARGET_PROJECT_TITLE}' project within the " - echo -n "'${GH_ORG_NAME}' organization might have been closed or " - echo -n "does not exist, as the obtained number for this project " - echo -n "is empty: the '${GH_ORG_NAME}' organization has no such " - echo -n "open project. Check the 'user_config' file to ensure " - echo -n "that you have correctly entered 'GH_ORG_NAME' and " - echo "'TARGET_PROJECT_TITLE'." - exit 1 - fi - else - echo "${project_number}" >&2 - exit 1 - fi - - # Retrieves the project items list and saves it as a JSON file. - gh project item-list "${project_number}" \ - --owner "${GH_ORG_NAME}" \ - --limit "${GH_PROJECT_MAX_ITEM}" \ - --format json | jq '.' > "${filename}" - - if [[ $? -eq 0 ]]; then - echo -n "${OK} The project '${TARGET_PROJECT_TITLE}' has been logged " - echo "to the file: ${filename}" - else - echo -n "${ERR} An error occurred while logging the project " >&2 - echo "'${TARGET_PROJECT_TITLE}'" >&2 - exit 1 - fi -} -# Closes a specified project within a GitHub organization. -# Usage: close_project -close_project() { - # Local variable declaration - local project_number - - project_number=$(get_project_number) - if [[ $? -eq 0 ]]; then - if [[ -n "${project_number}" ]]; then - echo -n "${YUP} Obtaining the number of the '${TARGET_PROJECT_TITLE}' " - echo "project has been completed" - else - echo -n "${WRN} The '${TARGET_PROJECT_TITLE}' project within the " - echo -n "'${GH_ORG_NAME}' organization might have been closed or " - echo -n "does not exist, as the obtained number for this project " - echo -n "is empty: the '${GH_ORG_NAME}' organization has no such " - echo -n "open project. Check the 'user_config' file to ensure " - echo -n "that you have correctly entered 'GH_ORG_NAME' and " - echo "'TARGET_PROJECT_TITLE'." - exit 1 - fi - else - echo "${project_number}" >&2 - exit 1 - fi - - # Close the specified project. - gh project close "${project_number}" \ - --owner "${GH_ORG_NAME}" - - if [[ $? -eq 0 ]]; then - echo "${OK} The project '${TARGET_PROJECT_TITLE}' has been closed" - else - echo -n "${ERR} An error occurred while closing the project " >&2 - echo "'${TARGET_PROJECT_TITLE}'" >&2 - exit 1 - fi -} - -# Delete project TARGET_PROJECT_TITLE defined in user_config.sh file from -# GH_ORG_NAME. Usage: delete_project -delete_project() { - # Local variable declaration - local project_number - - project_number=$(get_project_number) - if [[ $? -eq 0 ]]; then - if [[ -n "${project_number}" ]]; then - echo -n "${YUP} Obtaining the number of the '${TARGET_PROJECT_TITLE}' " - echo "project has been completed" - else - echo -n "${WRN} The '${TARGET_PROJECT_TITLE}' project within the " - echo -n "'${GH_ORG_NAME}' organization might have been closed or " - echo -n "does not exist, as the obtained number for this project " - echo -n "is empty: the '${GH_ORG_NAME}' organization has no such " - echo -n "open project. Check the 'user_config' file to ensure " - echo -n "that you have correctly entered 'GH_ORG_NAME' and " - echo "'TARGET_PROJECT_TITLE'." - exit 1 - fi - else - echo "${project_number}" >&2 - exit 1 - fi - - # deleting a project - gh project delete "${project_number}" \ - --owner "${GH_ORG_NAME}" - - if [[ $? -ne 0 ]]; then - echo -n "${ERR} An error occurred while deleting the " >&2 - echo "'${TARGET_PROJECT_TITLE}' from '${GH_ORG_NAME}'" >&2 - exit 1 - fi - echo -n "${OK} Deletion of the '${TARGET_PROJECT_TITLE}' project " - echo "has been completed." -} - - -# Creates a set of private repositories from templates without cloning them. -# Usage: create_repo_from_template -create_repo_from_template() { - # Iterate through the source repositories and create private repositories from - # templates - for ((i=0; i<"${#SOURCE_REPOS[@]}"; i++)); do - - # Create private repo from the template without cloning it. - gh repo create "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}" \ - --private \ - --template "${GH_ORG_NAME}"/"${SOURCE_REPOS[i]}" \ - --clone=false - - if [[ $? -ne 0 ]]; then - echo -n "${ERR} An error occurred while creating the " >&2 - echo "${GH_ORG_NAME}/${TARGET_REPOS[i]} repo from the " >&2 - echo "${GH_ORG_NAME}/${SOURCE_REPOS[i]} template" >&2 - exit 1 - fi - done - echo "${OK} Creating repos from templates has been completed" -} - -# Assigns a repository to a specific team within the GitHub organization. -# Usage: assign_repo_to_team -assign_repo_to_team() { - # Assigning a first argument to a local variable - local repo="$1" - - # Add a repository to the team - gh api \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - --method PUT \ - /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}"/repos/"${GH_ORG_NAME}"/"${repo}" \ - --raw-field permission="${GH_TEAM_REPO_PERMISSION}" \ - --silent - - # Check if the command succeeded - if [[ $? -eq 0 ]]; then - echo -n "Repository '${repo}' was successfully added to " - echo "the '${GH_TEAM_NAME}' team." - else - echo -n "${ERR} An error occurred while adding the repository '${repo}' " >&2 - echo "to the '${GH_TEAM_NAME}' team in the '${GH_ORG_NAME}' organization." >&2 - exit 1 - fi -} - -# Removes a repository from a specific team within the GitHub organization. -# Usage: remove_repo_from_team -remove_repo_from_team() { - # Assigning the first argument to a local variable - local repo="$1" - - # Remove the repository from the team - gh api \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - --method DELETE \ - /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}"/repos/"${GH_ORG_NAME}"/"${repo}" \ - --silent - - # Check if the command succeeded - if [[ $? -eq 0 ]]; then - echo -n "${YUP} Repository '${repo}' was successfully removed from " - echo "the '${GH_TEAM_NAME}' team." - else - echo -n "${ERR} An error occurred while removing the repository '${repo}' " >&2 - echo "from the '${GH_TEAM_NAME}' team in the '${GH_ORG_NAME}' organization." >&2 - exit 1 - fi -} - -# Get the team's repositories list. Usage: get_team_repos_list -get_team_repos_list() { - - # Declare a local variable for the api response to avoid printing potential - # errors to the pager; instead, we print them directly to the console. - local response - - # API request for a team's repos list - response=$(gh api \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - --method GET \ - /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}"/repos \ - --paginate \ - --jq '.[].name') - - # Check if the command succeeded - if [[ $? -eq 0 ]]; then - echo "${response}" - else - echo -n "${ERR} An error occurred while getting the repository list of " >&2 - echo "the '${GH_TEAM_NAME}' team in the '${GH_ORG_NAME}' organization." >&2 - exit 1 - fi -} - -get_team_members_list() { - - # Expect exactly one argument: name of output array - if [[ "$#" -ne 1 || -z "$1" ]]; then - echo "${ERR} Usage: ${FUNCNAME[0]} " >&2 - exit 1 - fi - - # Name reference to caller's array - local -n _team_members="$1" - local status - - mapfile -t _team_members < <( - gh api \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - --method GET \ - /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}"/members \ - --paginate \ - --jq '.[].login' - ) - status=$? - - if [[ $status -ne 0 ]]; then - echo -n "${ERR} An error occurred while obtaining the members list of " >&2 - echo "the '${GH_TEAM_NAME}' team from the '${GH_ORG_NAME}' organization." >&2 - exit 1 - fi -} - -get_team_member_role() { - - # Check if the correct number of non-empty arguments is passed - if [[ "$#" -ne 1 || -z "$1" ]]; then - echo "${ERR} Invalid number of arguments or empty argument." >&2 - echo "${WRN} Usage: ${FUNCNAME[0]} " - exit 1 - fi - - local member="$1" - # Declare a local variable for the return value - local member_role - - member_role=$(gh api \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - --method GET \ - /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}"/memberships/"${member}" \ - --jq '.role') - - if [[ $? -eq 0 ]]; then - echo "${member_role}" - else - echo -n "${ERR} An error occurred while obtaining the '${GH_TEAM_NAME}'" - echo " team member role in the '${GH_ORG_NAME}' organization. " - exit 1 - fi -} - -remove_team_members_from_org() { - - local -a team_members - local member_role - - # Fill array by reference - get_team_members_list team_members - - echo -n "${YUP} Obtaining the members list of the '${GH_TEAM_NAME}' " - echo "has been completed" - - for member in "${team_members[@]}"; do - member_role=$(get_team_member_role "${member}") - if [[ $? -eq 0 ]]; then - echo -n "${YUP} Obtaining the team role of '${member}' in the " - echo "'${GH_TEAM_NAME}' team has been completed" - else - echo "${member_role}" >&2 - exit 1 - fi - - echo "member: ${member}, role: ${member_role}" - # Remove user from the organization if they are a 'member' of the team - if [[ "${member_role}" == 'member' ]]; then - gh api \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - --method DELETE \ - /orgs/"${GH_ORG_NAME}"/members/"${member}" \ - --silent - - if [[ $? -eq 0 ]]; then - echo -n "'${member}' successfully removed from the '${GH_ORG_NAME}' " - echo "organization." - else - echo -n "Failed to remove '${member}' from the '${GH_ORG_NAME}' " - echo "organization." - exit 1 - fi - else - echo "'${member}' is not a 'member' role user in the team, skipping..." - fi - done -} - -# Deletes a specified team from the GitHub organization. -# Usage: delete_team -delete_team() { - # API request for a team deletion - gh api \ - --method DELETE \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}" \ - --silent - - # Check whether the operation was successful - if [[ $? -eq 0 ]]; then - echo -n "${OK} The '${GH_TEAM_NAME}' team has been deleted " - echo "from the '${GH_ORG_NAME}' organization." - else - echo -n "${ERR} An error occurred while deleting the '${GH_TEAM_NAME}'" >&2 - echo " team from the '${GH_ORG_NAME}' organization." >&2 - exit 1 - fi -} - -# Deletes the entire GitHub organization. -# Usage: delete_organization -delete_organization() { - # API request for an organization deletion - gh api \ - --method DELETE \ - --header "Accept: ${GH_API_ACCEPT_HEADER}" \ - --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ - /orgs/"${GH_ORG_NAME}" \ - --silent - - # Check whether the operation was successful - if [[ $? -eq 0 ]]; then - echo "${OK} The '${GH_ORG_NAME}' organization has been deleted " - else - echo -n "${ERR} An error occurred while deleting the " >&2 - echo "'${GH_ORG_NAME}' organization" >&2 - exit 1 - fi -} - -# VALIDATION FUNCTIONS----------------------------------------------------------# -# Check if GitHub CLI (gh) is installed -check_if_gh_installed() { - if ! command -v gh &> /dev/null; then - echo -n "${ERR} GitHub CLI (gh) is not installed on your computer. " >&2 - echo "Install to continue: https://cli.github.com/" >&2 - return 1 - elif [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} 'GitHub CLI (gh)' is installed on your computer." - fi -} - -check_if_git_installed() { - if ! command -v git &> /dev/null; then - echo -n "${ERR} 'git' is not installed on your computer. " >&2 - echo "Install to continue: https://www.git-scm.com/downloads" >&2 - return 1 - elif [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} 'git' is installed on your computer." - fi -} - -check_if_sed_installed() { - if ! command -v sed &> /dev/null; then - echo -n "${ERR} 'sed' – a command-line utility for parsing and " >&2 - echo -n "transforming text is not installed. Install it to continue: " >&2 - echo "https://www.gnu.org/software/sed/" >&2 - return 1 - elif [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} 'sed' is installed on your computer." - fi -} - -check_if_grep_installed() { - if ! command -v grep &> /dev/null; then - echo -n "${ERR} 'grep' – a command-line utility for searching text " >&2 - echo -n "that match a regular expression is not installed. " >&2 - echo "Install it to continue:https://www.gnu.org/software/grep/ " >&2 - return 1 - elif [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} 'grep' is installed on your computer." - fi -} - -check_if_jq_installed() { - if ! command -v jq &> /dev/null; then - echo -n "${ERR} 'jq' – a lightweight and flexible command-line JSON " >&2 - echo -n "processor is not installed. Install it to continue. " >&2 - echo "https://stedolan.github.io/jq/download/" >&2 - return 1 - elif [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} 'jq' is installed on your computer." - fi -} - -check_if_date_installed() { - if ! command -v date &> /dev/null; then - echo -n "${ERR} 'date' – a command-line utility for displaying the " >&2 - echo -n "system date and time is not installed. Install it to continue. " >&2 - echo "https://www.gnu.org/software/coreutils/" >&2 - return 1 - elif [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} 'date' is installed on your computer." - fi -} - -check_if_touch_installed() { - if ! command -v touch &> /dev/null; then - echo -n "${ERR} 'touch' – a command-line utility for changing file " >&2 - echo -n "timestamps is not installed. Install it to continue. " >&2 - echo "https://www.gnu.org/software/coreutils/" >&2 - return 1 - elif [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} 'touch' is installed on your computer." - fi -} - -validate_non_empty_string() { - if [[ "$#" -lt 2 ]]; then - echo -n "${ERR} Insufficient arguments provided for the " >&2 - echo "'${FUNCNAME[0]}' function." >&2 - exit 1 - fi - - local var_name="$1" - local var_value="$2" - - if [[ -z "${var_value}" ]]; then - echo "${ERR} '${var_name}' is empty." >&2 - return 1 - elif [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} '${var_name}' is non-empty: '${var_value}'." - fi -} - -validate_owner_name() { - if [[ "$#" -lt 2 ]]; then - echo -n "${ERR} Insufficient arguments provided for the " >&2 - echo "'${FUNCNAME[0]}' function." >&2 - exit 1 - fi - - local var_name="$1" - local var_value="$2" - - if [[ "${var_value}" =~ ^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$ ]]; then - if [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} '${var_name}' has valid value: '${var_value}'." - fi - else - echo "${ERR} '${var_name}' has invalid value: '${var_value}'." >&2 - return 1 - fi -} - -validate_team_name() { - if [[ "$#" -lt 2 ]]; then - echo -n "${ERR} Insufficient arguments provided for the " >&2 - echo "'${FUNCNAME[0]}' function." >&2 - exit 1 - fi - - local var_name="$1" - local var_value="$2" - - if [[ "${var_value}" =~ ^[a-zA-Z0-9]+([_-][a-zA-Z0-9]+)*$ ]]; then - if [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} '${var_name}' has valid value: '${var_value}'." - fi - else - echo "${ERR} '${var_name}' has invalid value: '${var_value}'." >&2 - return 1 - fi -} - -validate_numeric() { - if [[ "$#" -lt 2 ]]; then - echo -n "${ERR} Insufficient arguments provided for the " >&2 - echo "'${FUNCNAME[0]}' function." >&2 - exit 1 - fi - - local var_name="$1" - local var_value="$2" - - if ! [[ "${var_value}" =~ ^[0-9]+$ ]]; then - echo "${ERR} '${var_name}' has not numeric value: '${var_value}'." >&2 - return 1 - elif [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} '${var_name}' has numeric value: '${var_value}'." - fi -} - -# Function to validate non-empty arrays -validate_non_empty_array() { - if [[ "$#" -lt 2 ]]; then - echo -n "${ERR} Insufficient arguments provided for the " >&2 - echo "'${FUNCNAME[0]}' function." >&2 - exit 1 - fi - - local var_name="$1" - shift # Remove the first argument to treat the rest as an array - local -a arr=("$@") - - if [[ "${#arr[@]}" -eq 0 ]]; then - echo "${ERR} '${var_name}' is an empty array." >&2 - return 1 - elif [[ "${VERBOSE}" -eq 1 ]]; then - echo - echo "${YUP} '${var_name}' is a non-empty array: ${arr[*]}." - echo - fi -} - -# Function to validate no empty elements in an array -validate_no_empty_array_elements() { - if [[ "$#" -lt 2 ]]; then - echo -n "${ERR} Insufficient arguments provided for the " >&2 - echo "'${FUNCNAME[0]}' function." >&2 - exit 1 - fi - - local var_name="$1" - shift # Remove the first argument to treat the rest as an array - local -a arr=("$@") - - for element in "${arr[@]}"; do - if [[ -z "${element}" ]]; then - echo "${ERR} '${var_name}' contains an empty element." >&2 - return 1 - fi - done - - if [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} '${var_name}' has no empty elements." - fi -} - -# Function to validate repo names for each element in an array -validate_repo_names() { - if [[ "$#" -lt 2 ]]; then - echo -n "${ERR} Insufficient arguments provided for the " >&2 - echo "'${FUNCNAME[0]}' function." >&2 - exit 1 - fi - - local var_name="$1" - shift # Remove the first argument to treat the rest as an array - local -a arr=("$@") - - for element in "${arr[@]}"; do - if ! [[ "${element}" =~ ^[a-zA-Z0-9_.-]+$ ]]; then - echo -n "${ERR} '${var_name}' contains a non-valid element: " >&2 - echo -n "'${element}'. Repository names can only contain alphanumeric " >&2 - echo -n "characters, dot ('.'), hyphen-minus character ('-'), " >&2 - echo "and the underscore ('_')." >&2 - return 1 - fi - done - - if [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} All elements in '${var_name}' are valid." - fi -} - -# Function to validate user names for each element in an array -validate_user_names() { - # The array of user names can be empty, so we need to check if at least one - # element is provided. - if [[ "$#" -lt 1 ]]; then - echo -n "${ERR} Insufficient arguments provided for the " >&2 - echo "'${FUNCNAME[0]}' function." >&2 - exit 1 - fi - - local var_name="$1" - shift # Remove the first argument to treat the rest as an array - local -a arr=("$@") - - # Proceed only if the array is not empty - if [[ "${#arr[@]}" -eq 0 ]]; then - if [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} The array '${var_name}' is empty. No validation needed." - fi - return 0 - fi - - for element in "${arr[@]}"; do - if ! [[ "${element}" =~ ^[a-zA-Z0-9-]+$ ]]; then - echo -n "${ERR} '${var_name}' contains a non-valid element: " >&2 - echo -n "'${element}'. User names can be empty or contain alphanumeric" >&2 - echo " characters and hyphen-minus character ('-')." >&2 - return 1 - fi - done - - if [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} All elements in '${var_name}' are valid." - fi -} - -# Function to check for duplicate elements in an array -validate_no_duplicates() { - # Evaluation of this function needs 'set +u', otherwise will be terminated by - # 'set -u' - local is_set_nounset='' - - [[ "$-" == *u* ]] && is_set_nounset='true' || is_set_nounset='false' - set +u - - if [[ "$#" -lt 2 ]]; then - echo -n "${ERR} Insufficient arguments provided for the " >&2 - echo "'${FUNCNAME[0]}' function." >&2 - exit 1 - fi - - local var_name="$1" - shift # Remove the first argument to treat the rest as an array - local -a arr=("$@") - local -A arr_map=() - - for element in "${arr[@]}"; do - if [[ -n "${arr_map[${element}]}" ]]; then - echo "${ERR} '${var_name}' has duplicate elements: '${element}'." >&2 - return 1 - fi - arr_map["${element}"]=1 - done - - if [[ "${VERBOSE}" -eq 1 ]]; then - echo "${YUP} '${var_name}' has no duplicates." - fi - - "${is_set_nounset}" || set +u -} - -# TODO: -# Add function validations on global variables from system_config.sh -# END: - -# THE MAIN COMMANDS-------------------------------------------------------------# -# The 'delete' command deletes the entire GitHub organization. Depends on the -# 'user_config.sh' and 'system_config.sh' files. For organization deletion we -# need the 'admin:org' scope. -delete() { - - # Asking the user for confirmation for deleting the organization - echo -n "${WRN} Are you sure you want to delete the organization " - echo "'${GH_ORG_NAME}'? (no/yes) [no]:" - read user_confirm - - if [[ "${user_confirm}" == 'yes' ]]; then - # Requesting the user to type the organization name for final confirmation - echo "${WRN} Please type the name of the organization to confirm deletion: " - read typed_org_name - - if [[ "${typed_org_name}" == "${GH_ORG_NAME}" ]]; then - echo 'Running delete_organization()' - delete_organization - else - echo -n "${WRN} The typed name '${typed_org_name}' does not match " - echo "the organization name '${GH_ORG_NAME}'. Aborting deletion." - fi - else - echo "${WRN} Deletion aborted by the user." - fi -} - -# The 'close' command concludes a course by archiving project data and -# optionally deleting the team associated with the course. Depends on the -# 'user_config.sh' and 'system_config.sh' files. -close() { - - # Archive data before closing the project - echo 'Running get_project_data_as_json()' - get_project_data_as_json - - echo 'Running close_project()' - close_project - - # Asking the user for confirmation for deleting the team - echo -n "${WRN} Are you sure you want to delete the team '${GH_TEAM_NAME}' " - echo "from organization '${GH_ORG_NAME}'? (no/yes) [no]:" - read user_confirm - - # Asking the user for confirmation for removing members from an organization - if [[ "${user_confirm}" == 'yes' ]]; then - echo -n "${WRN} Do you want the team members, excluding the team " - echo -n "maintainers, to be removed from the organization '${GH_ORG_NAME}'?" - echo " (no/yes) [no]:" - read user_confirm - - if [[ "${user_confirm}" == 'yes' ]]; then - echo 'Running remove_team_members_from_org()' - remove_team_members_from_org - fi - - echo 'Running delete_team()' - delete_team - fi -} - -# The 'status' command list the repositories added to the team -# 'GH_TEAM_NAME'. Depends on the 'user_config.sh' and 'system_config.sh' files. -status() { - - echo 'Running get_team_repos_list()' - get_team_repos_list -} - -# The 'log' commnad captures and logs the current state of a specified GitHub -# project 'TARGET_PROJECT_TITLE' into a JSON file for archival or potential -# analysis purposes. Depends on the 'user_config.sh' file. -log() { - - echo 'Running get_project_data_as_json()' - get_project_data_as_json -} - -# The 'unassign' command removes a specified repository from a team -# 'GH_TEAM_NAME' within the GitHub organization 'GH_ORG_NAME'. Depends on the -# 'user_config.sh' and 'system_config.sh' files. Usage: unassign -unassign() { - - # Assigning a first argument – repository name – to a local variable. - local repo="$1" - - echo 'Running remove_repo_from_team()' - remove_repo_from_team "${repo}" -} - -# The 'assign' command assigns a specified repository to a team 'GH_TEAM_NAME' -# within the GitHub organization 'GH_ORG_NAME'. Depends on the 'user_config.sh' -# and 'system_config.sh' files. Usage: assign -assign() { - - # Assigning a first argument – repository name – to a local variable. - local repo="$1" - - echo 'Running add_repo_to_team()' - assign_repo_to_team "${repo}" -} - -disinvite() { - # This API operation needs the "admin:org" scope which is not set by - # default. To request it, run: gh auth refresh -h github.com -s - # admin:org - echo 'Running cancel_invitations_to_organization()' - cancel_invitations_to_organization -} - -invite() { - # Disable the script's trap on ERR (because we don't want to print info about - # error but just warning if the HTTP status code is 422). - set +o errtrace - - # This API operation needs the "admin:org" scope which is not set by default. To - # request it, run: gh auth refresh -h github.com -s admin:org - echo 'Running invite_to_organization_team()' - invite_to_organization_team -} - -# Opening a course involves, in addition to making some necessary organizational -# settings, creating a 'GH_TEAM_NAME' team to which students will be added after -# they accept the invitations to join the organization sent during the course -# opening operation. Team settings are adjusted here to ensure the mechanism for -# the automatic selection of a reviewer functions properly. Individuals excluded -# from reviewing, such as teachers, are listed in -# 'GH_TEAM_MEMBERS_EXCLUDED_FROM_REVIEWING'. Subsequently, a GitHub project -# titled 'TARGET_PROJECT_TITLE' is linked to the 'GH_TEAM_NAME' team. At this -# stage, task repositories are cloned to a local folder. Finally, repositories -# containing tasks are configured to protect them against modifications by -# students. Students are required to fork the repository to their account in -# order to make their changes. Depends on the 'user_config.sh' and -# 'system_config.sh' files. -open() { - - # Aborts the script when the first error is detected. - set -o errexit - - if [[ ! -f 'emails.txt' ]]; then - echo -n "${WRN} The 'emails.txt' file is missing. Please run " - echo "the 'gh soc init' command to create the missing file." - exit 1 - fi - - # Check if username and email for git have been configured. - check_git_configs - - # Target repositories validation - # Check if the newly created target repositories on GH_ORG_NAME by `sync - # command` exist so that we can clone them. - echo 'Running check_if_repos_exist()' - check_if_repos_exist "${GH_ORG_NAME}" \ - "TARGET_REPOS" \ - "${TARGET_REPOS[@]}" - - # Disable the script's abort-on-first-error feature. - set +o errexit - - echo 'Running update_org_default_repo_permission()' - update_org_default_repo_permission - echo 'Running update_org_members_can_fork_private_repo()' - update_org_members_can_fork_private_repo - - echo 'Running create_team()' - create_team - - # By default, the command below sets the team's project permissions to read. - # IDEA: it may be worth implementing function for changing the Organization - # settings: Settings –> Member privileges –> Projects base permissions [Read] - # "Projects created by members will default to the elected role." - # get_team_id – gh api graphql - # link_project_to_team – gh api graphql - echo 'Running link_project_to_team()' - link_project_to_team - - # UpdateTeamReviewAssignmentInput is available under the Team review - # assignments preview. During the preview period, the API may change without - # notice. - # https://docs.github.com/en/graphql/reference/mutations#updateteamreviewassignment - # https://docs.github.com/en/graphql/reference/input-objects#updateteamreviewassignmentinput - - # TeamReviewAssignmentAlgorithm is available under the Team review - # assignments preview. During the preview period, the API may change without - # notice. \"$TeamReviewAssignmentAlgorithm\", - # get_team_id – gh api graphql - # enable_team_review_assignment – gh api graphql - echo 'Running enable_team_review_assignment()' - enable_team_review_assignment - - # It is required to run sync to create target repositories on the GH_ORG_NAME - # account - all functions below depend on it - echo 'Running clone_repositories()' - clone_repositories - - # !!!!!!!These functions require local repositories!!!!!!!!!!!!!!!!!!!!! - - # # [GitHub docs]: You can define code owners in public repositories with GitHub - # # Free and GitHub Free for organizations, and in public and private - # # repositories with GitHub Pro, GitHub Team, GitHub Enterprise Cloud, and - # # GitHub Enterprise Server. - echo 'Running set_team_as_codeowners()' - set_team_as_codeowners - - # Aborts the script when the first error is detected. - set -o errexit - - echo 'Running commit_changes_and_push_to_remote_repos()' - commit_changes_and_push_to_remote_repos - # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! - - # Disable the script's abort-on-first-error feature. - set +o errexit - - # # [GitHub docs]: Protected branches are available in public repositories with - # # GitHub Free and GitHub Free for organizations, and in public and private - # # repositories with GitHub Pro, GitHub Team, GitHub Enterprise Cloud, and - # # GitHub Enterprise Server. - # # [GitHub docs]: Branch is read-only. Users cannot push to the branch. - echo 'Running lock_branch()' - lock_branch - - # [GitHub docs]: When enabled, all conversations on code must be resolved before - # a pull request can be merged into a branch that matches this rule - echo 'Running require_conversation_resolution_before_merging()' - require_conversation_resolution_before_merging - - # GitHub Pages is available in private repositories with GitHub Pro, GitHub - # Team, GitHub Enterprise Cloud, and GitHub Enterprise Server. - echo 'Running swich_on_gh_pages()' - swich_on_gh_pages - echo 'Running set_website_link()' - set_repos_website -} - -unsync() { - # Aborts the script when the first error is detected. - set -o errexit - - local unsync_from="$1" - - # Disable the script's abort-on-first-error feature. - set +o errexit - - if [[ "${unsync_from}" == 'project' || "${unsync_from}" == '' ]]; then - # Asking the user for confirmation for removing the project - echo -n "${WRN} Are you sure you want to remove the ${TARGET_PROJECT_TITLE}" - echo " project from the organization '${GH_ORG_NAME}'? (no/yes) [no]:" - read user_confirm - - if [[ "${user_confirm}" == 'yes' ]]; then - echo 'Running delete_project()' - delete_project - fi - fi - - if [[ "${unsync_from}" == 'repos' || "${unsync_from}" == '' ]]; then - # Aborts the script when the first error is detected. - set -o errexit - # Target repositories validation - # Check if the target repositories exist so that we can delete them. - echo 'Running check_if_repos_exist()' - check_if_repos_exist "${GH_ORG_NAME}" \ - "TARGET_REPOS" \ - "${TARGET_REPOS[@]}" - - # Disable the script's abort-on-first-error feature. - set +o errexit - - # Asking the user for confirmation for removing all repos - echo -n "${WRN} Are you sure you want to remove all " - echo "repositories listed in 'TARGET_REPOS' array: " - printf "%s\n" "${TARGET_REPOS[@]}" - echo "from the organization '${GH_ORG_NAME}'? (no/yes) [no]:" - read user_confirm - - if [[ "${user_confirm}" == 'yes' ]]; then - echo 'Running delete_repositories()' - delete_repositories - fi - fi -} - -# This command creates a GitHub project titled 'TARGET_PROJECT_TITLE' from the -# template number 'SOURCE_PROJECT_NUMBER' and 'SOURCE_PROJECT_OWNER' GitHub -# account, as specified in source_config. This project is utilized to gather -# information on work progress. Subsequently, repositories with names defined in -# 'TARGET_REPOS' array in the user_config.sh file are created based on templates -# repostories defined in 'SOURCE_REPOS' array in the source_config.sh -# file. Depends on the 'user_config.sh', 'source_config.sh' and -# 'system_config.sh' files. -sync() { - - # Aborts the script when the first error is detected. - set -o errexit - - local sync_with="$1" - - # Disable the script's abort-on-first-error feature. - set +o errexit - - if [[ "${sync_with}" == 'project' || "${sync_with}" == '' ]]; then - # In an organization with the Team plan, we have more opportunities to create - # charts in Insights than with a user account on the 'Pro' plan. For example, - # we can create a graph where x represents time. - echo 'Running create_project_from_template()' - create_project_from_template - fi - - if [[ "${sync_with}" == 'repos' || "${sync_with}" == '' ]]; then - # Aborts the script when the first error is detected. - set -o errexit - - # Source repositories validation - # Check if the source repositories exist so that we can use them. We can't - # start repos creating if one of these doesn't exist. - echo 'Running check_if_repos_exist()' - check_if_repos_exist "${SOURCE_REPOS_OWNER}" \ - "SOURCE_REPOS" \ - "${SOURCE_REPOS[@]}" - - # Checks if source repositories are templates, so that we can create - # repositories based on them. Attempt to make them templates if they are not - # already. - ensure_repos_as_templates "${SOURCE_REPOS_OWNER}" \ - "SOURCE_REPOS" \ - "${SOURCE_REPOS[@]}" - - # Target repositories validation - # Check if the target repositories not yet exist so that we can create them - echo 'Running check_if_repos_noexist()' - check_if_repos_noexist "${GH_ORG_NAME}" \ - "TARGET_REPOS" \ - "${TARGET_REPOS[@]}" - - - # Disable the script's abort-on-first-error feature. - set +o errexit - - # Create repositories defined in the 'TARGET_REPOS' array in the - # 'user_config.sh' from the 'SOURCE_REPOS' repository templates array in the - # 'source_config.sh' file. - echo 'Running create_private_repos_from_templates()' - create_private_repos_from_templates - echo 'Running set_repos_descriptions()' - set_repos_descriptions - echo 'Running update_repo_without_cloning()' - update_repo_without_cloning - fi -} - -# Performs global variable validations from configuration files. Depends on -# the 'user_config.sh' and 'source_config.sh' files. -precheck() { - # Variable that tracks when an error occurred - local error_occurred=0 - # The first argument of precheck is the verbosity level 0 or 1; 0 is default - local VERBOSE=${1:-0} - - if [[ "${VERBOSE}" -eq 1 ]]; then - echo "Commands validation:" - fi - if ! check_if_gh_installed; then error_occurred=1; fi - if ! check_if_git_installed; then error_occurred=1; fi - if ! check_if_sed_installed; then error_occurred=1; fi - if ! check_if_grep_installed; then error_occurred=1; fi - if ! check_if_jq_installed; then error_occurred=1; fi - if ! check_if_date_installed; then error_occurred=1; fi - if ! check_if_touch_installed; then error_occurred=1; fi - - if [[ "${VERBOSE}" -eq 1 ]]; then - echo "Validation of global variables from the 'user_config.sh' file:" - fi - # Variables validations - if ! validate_owner_name "GH_ORG_NAME" "${GH_ORG_NAME}"; then - error_occurred=1; - fi - - if ! validate_team_name "GH_TEAM_NAME" "${GH_TEAM_NAME}"; then - error_occurred=1; - fi - if ! validate_user_names "GH_TEAM_MEMBERS_EXCLUDED_FROM_REVIEWING" \ - "${GH_TEAM_MEMBERS_EXCLUDED_FROM_REVIEWING[@]}"; then - error_occurred=1; - fi - - if ! validate_non_empty_string "TARGET_PROJECT_TITLE" "${TARGET_PROJECT_TITLE}"; then - error_occurred=1; - fi - - # Arrays validations - if ! validate_non_empty_array "TARGET_REPOS" "${TARGET_REPOS[@]}"; then - error_occurred=1; - fi - if ! validate_repo_names "TARGET_REPOS" "${TARGET_REPOS[@]}"; then - error_occurred=1; - fi - if ! validate_no_duplicates "TARGET_REPOS" "${TARGET_REPOS[@]}"; then - error_occurred=1; - fi - - if [[ "${VERBOSE}" -eq 1 ]]; then - echo "Validation of global variables from the 'source_config.sh' file:" - fi - # Variable validations - if ! validate_owner_name "SOURCE_REPOS_OWNER" "${SOURCE_REPOS_OWNER}"; then - error_occurred=1; - fi - if ! validate_owner_name "SOURCE_PROJECT_OWNER" "${SOURCE_PROJECT_OWNER}"; then - error_occurred=1; - fi - if ! validate_numeric "SOURCE_PROJECT_NUMBER" "${SOURCE_PROJECT_NUMBER}"; then - error_occurred=1; - fi - - # Arrays validations - if ! validate_non_empty_array "SOURCE_REPOS" "${SOURCE_REPOS[@]}"; then - error_occurred=1; - fi - if ! validate_repo_names "SOURCE_REPOS" "${SOURCE_REPOS[@]}"; then - error_occurred=1; - fi - if ! validate_no_duplicates "SOURCE_REPOS" "${SOURCE_REPOS[@]}"; then - error_occurred=1; - fi - if ! validate_non_empty_array "SOURCE_MAIN_REPO_INFO_FILES" "${SOURCE_MAIN_REPO_INFO_FILES[@]}"; then - error_occurred=1; - fi - if ! validate_no_empty_array_elements "SOURCE_MAIN_REPO_INFO_FILES" "${SOURCE_MAIN_REPO_INFO_FILES[@]}"; then - error_occurred=1; - fi - if ! validate_no_duplicates "SOURCE_MAIN_REPO_INFO_FILES" "${SOURCE_MAIN_REPO_INFO_FILES[@]}"; then - error_occurred=1; - fi - - # Check if any error occurred - if [[ "${error_occurred}" -eq 1 ]]; then - echo "{$ERR} Errors occurred during precheck. " >&2 - exit 1 - fi -} - -# The init command creates configuration files user_config.sh, source_config.sh, -# system_config.sh, and an additional file for student emails named -# emails.txt. It also creates a folder named 'logs' to store JSON data files. -init() { -# Aborts the script when the first error is detected. -set -o errexit - -# Create directory if it doesn't already exist -mkdir -p logs - -# Define the path for configuration files -local user_config_path="./user_config.sh" -local source_config_path="./source_config.sh" -local system_config_path="./system_config.sh" - -# Define the path for emails file -local emails_path="./emails.txt" - -# Check if the emails.txt file already exists, if not, create it with -# example values -if [[ ! -f "${emails_path}" ]]; then -cat << 'EOF' > "${emails_path}" -first@example.com -second@example.com -third@example.com -EOF -echo "${YUP} The file ${emails_path} has been created." -else - echo "${WRN} The file ${emails_path} already exists." -fi - -# Check if the user_config.sh file already exists, if not, create it with -# default values -if [[ ! -f "${user_config_path}" ]]; then -cat << 'EOF' > "${user_config_path}" -# ORGANIZATION CONFIGS----------------------------------------------------------# -# The organization name may only contain alphanumeric characters or single -# hyphens, and cannot begin or end with a hyphen ('-'). -readonly GH_ORG_NAME='' # insert here your GitHub organization name - -# TEAM CONFIGS------------------------------------------------------------------# -# The team name name may contain only alphanumeric characters, a hyphen-minus -# character ('-'), and an underscore ('_'). -readonly GH_TEAM_NAME='SoC-DS' - -# The team description may contain any character. -readonly GH_TEAM_DESCRIPTION='SoC Data Science Course' - -# The github logins may only contain alphanumeric characters or single -# hyphens, and cannot begin or end with a hyphen ('-'). -readonly GH_TEAM_MEMBERS_EXCLUDED_FROM_REVIEWING=() - -# REPOSITORY CONFIGS------------------------------------------------------------# -# The repository description may contain any character. -readonly TARGET_REPO_DESCRIPTION='SoC Data Science Course' - -# New repository names: rename according to your preferences, but choose names -# that are not part of others. The first repository name may be an exception, as -# in our example: the repository name 'soc-datascience' is part of others. Names -# may contain only alphanumeric characters, a hyphen-minus -# character ('-'), an underscore ('_') and a dot ('.'). -readonly TARGET_REPOS=( - 'soc-datascience' - 'soc-datascience-hello' - 'soc-datascience-viz' - 'soc-datascience-wrang' - 'soc-datascience-spatial' - 'soc-datascience-reshape' - 'soc-datascience-paradox' - 'soc-datascience-collabor' - 'soc-datascience-scrap' - 'soc-datascience-bias' - 'soc-datascience-fitting' - 'soc-datascience-nfitting' - 'soc-datascience-valid' - 'soc-datascience-hypo' - 'soc-datascience-wrapup' - 'soc-datascience-project' -) - -# PROJECT CONFIGS---------------------------------------------------------------# -# The project title may contain any character. -readonly TARGET_PROJECT_TITLE='monitor-SoC-DS' -EOF -echo "${YUP} The file ${user_config_path} has been created." -else - echo "⚡ The file ${user_config_path} already exists." -fi - -# Check if the source_config.sh file already exists, if not, create it with -# default values -if [[ ! -f "${source_config_path}" ]]; then -cat << 'EOF' > "${source_config_path}" -# REPOSITORY CONFIGS------------------------------------------------------------# -readonly SOURCE_REPOS_OWNER='the-soc-org' -readonly SOURCE_REPOS=( - 'soc-datascience' - 'soc-datascience-hello' - 'soc-datascience-viz' - 'soc-datascience-wrang' - 'soc-datascience-spatial' - 'soc-datascience-reshape' - 'soc-datascience-paradox' - 'soc-datascience-collabor' - 'soc-datascience-scrap' - 'soc-datascience-bias' - 'soc-datascience-fitting' - 'soc-datascience-nfitting' - 'soc-datascience-valid' - 'soc-datascience-hypo' - 'soc-datascience-wrapup' - 'soc-datascience-project' -) - -# The main repo is the first repository listed in the SOURCE_REPOS array. At -# least one info file must exist in the main repository. If there is no info -# file, an error message will be displayed. -readonly SOURCE_MAIN_REPO_INFO_FILES=( - 'README.md' - 'index.md' -) - -# PROJECT CONFIGS---------------------------------------------------------------# -readonly SOURCE_PROJECT_OWNER='the-soc-org' -readonly SOURCE_PROJECT_NUMBER='1' -EOF -echo "${YUP} The file ${source_config_path} has been created." -else - echo "${WRN} The file ${source_config_path} already exists." -fi - -# Check if the system_config.sh file already exists, if not, create it with -# default values -if [[ ! -f "${system_config_path}" ]]; then -cat << 'EOF' > "${system_config_path}" -# [GitHub API docs]: https://docs.github.com/en/rest?apiVersion=2022-11-28 -# [GitHub GraphQL docs]: https://docs.github.com/en/graphql/reference - -# API HEADERS CONFIGS-----------------------------------------------------------# -readonly GH_API_ACCEPT_HEADER='application/vnd.github+json' -readonly GH_API_VERSION_HEADER='2022-11-28' - -# GITHUB CLI CONFIGS------------------------------------------------------------# -readonly GH_CLI_TOKEN_SCOPES=('admin:org' 'delete_repo' 'project' 'repo') - -# ORGANIZATION CONFIGS----------------------------------------------------------# -# [GitHub API docs] The role for the new member. - -# admin – Organization owners with full administrative rights to the -# organization and complete access to all repositories and teams. - -# direct_member – Non-owner organization members with ability to see other -# members and join teams by invitation. - -# billing_manager – Non-owner organization members with ability to manage the -# billing settings of your organization. - -# reinstate – The previous role assigned to the invitee before they were removed -# from your organization. Can be one of the roles listed above. Only works if -# the invitee was previously part of your organization. - -readonly GH_ORG_ROLE='direct_member' - -# [GitHub API docs] Default permission level members have for organization -# repositories. Can be one of: read, write, admin, none. GitHub default: read -readonly GH_ORG_DEFAULT_REPOSITORY_PERMISSION='none' - -# [GitHub API docs] Whether organization members can fork private organization -# repositories. GitHub Default: false; the value other than 'false' will be -# treated as 'true' -readonly GH_ORG_MEMBERS_CAN_FORK_PRIVATE_REPOSITORIES=true - -# TEAM CONFIGS------------------------------------------------------------------# -# [GitHub API docs] Team privacy settings. -# secret – only visible to organization owners and members of this team. -# closed – visible to all members of this organization. -# GitHub default: secret -readonly GH_TEAM_PRIVACY='closed' - -# [GitHub API docs] The permission to grant the team on this repository. We -# accept the following permissions to be set: pull, triage, push, maintain, admin -# and you can also specify a custom repository role name, if the owning -# organization has defined any. If no permission is specified, the team's -# permission attribute will be used to determine what permission to grant the team -# on this repository. -# GitHub default: push -readonly GH_TEAM_REPO_PERMISSION='push' - -# [GitHub GraphQL API docs] Notifications settings. -# notifications_enabled – Team members get notified when the team is @mentioned. -# notifications_disabled – no one receives notifications. -# GitHub default: notifications_enabled -readonly GH_TEAM_NOTIFICATIONS='notifications_enabled' - -# [GitHub GraphQL API docs] The algorithm to use for review assignment -# LOAD_BALANCE – Balance review load across the entire team. -# ROUND_ROBIN – Alternate reviews between each team member. -readonly GH_TEAM_REVIEW_ASSIGNMENT_ALGORITHM='LOAD_BALANCE' - -# [GitHub GraphQL API docs] Notify the entire team of the PR if it is delegated -readonly GH_TEAM_NOTIFY=false - -# [GitHub GraphQL API docs] The number of team members randomly selected to -# review the PR -readonly GH_TEAM_MEMBER_COUNT=1 - -# REPOSITORY CONFIGS------------------------------------------------------------# -# [GitHub API docs] The name of the branch. Cannot contain wildcard characters. -readonly GH_REPO_BRANCH='main' - -# PROJECT CONFIGS---------------------------------------------------------------# -# Maximum number of items to fetch from project; log commnad uses it -readonly GH_PROJECT_MAX_ITEM=1000 -EOF -echo "${YUP} The file ${system_config_path} has been created." -else - echo "${WRN} The file ${system_config_path} already exists." -fi - -echo "${YUP} Initialization complete." -} - -main() { - # Check if an argument was provided - if [[ "$#" -eq 0 ]]; then - echo -n "${WRN} Usage: gh soc [init|precheck|sync|unsync|open|close|" - echo "invite|disinvite|assign|unassign|log|status|delete]" - exit 1 - fi - - if [[ "$1" != 'init' ]]; then - # If exist, source the configuration files - if [[ -f 'user_config.sh' && -f 'source_config.sh' && -f 'system_config.sh' ]]; then - source user_config.sh - source source_config.sh - source system_config.sh - else - echo "${WRN} The 'user_config.sh', 'source_config.sh' and/or 'system_config.sh' " - echo "file is missing. Please run the 'gh soc init' command to create the " - echo "missing file(s)." - exit 1 - fi - fi - - if [[ "$1" != 'init' && "$1" != 'precheck' ]]; then - # Silently validate global variables before all function except 'init' and - # 'precheck' - precheck 0 - - # Check if the user has the required token scopes defined in the - # 'GH_TOKEN_SCOPES' in th 'system_config.sh' file. The command 'unsync' - # requires the 'delete_repo' scope for repository deletion and the 'project' - # scope for project deletion. For logging the project, we need the - # 'read:project' scope, but other commands require more permissions. For - # example, the 'project' scope is required by 'open' command to link the - # project to a team, by the 'sync' command to create the project, and the - # 'close' command to close the project. The 'open' command requires the - # 'write:org' scope, while the 'invite', 'disinvite', and 'delete' commands - # require the 'admin:org' scope. - check_if_gh_has_required_token_scopes - fi - - # Run the appropriate function based on the argument - case "$1" in - init) - init - ;; - precheck) - # Validate global variables with verbosity level 1 - precheck 1 - ;; - sync) - # With an optional argument, the user specifies the source with which they - # want to synchronize; argument values: 'project' or 'repos'. Without the - # argument both the project and the repos will be synchronized. - sync "$2" - ;; - unsync) - # With an optinal argument, the user specifies the source from which they - # want to unsynchronize; argument values: 'project' or 'repos'. Without - # the argument both the project and the repos will be unsynchronized. - unsync "$2" - ;; - open) - open - ;; - close) - close - ;; - invite) - invite - ;; - disinvite) - disinvite - ;; - assign) - # Check if the correct number of non-empty arguments is passed - if [[ "$#" -ne 2 || -z "$2" ]]; then - echo "${ERR} Invalid number of arguments or empty argument." >&2 - echo "${WRN} Usage: gh soc assign " - exit 1 - fi - assign "$2" - ;; - unassign) - # Check if the correct number of non-empty arguments is passed - if [[ "$#" -ne 2 || -z "$2" ]]; then - echo "${ERR} Invalid number of arguments or empty argument." >&2 - echo "${WRN} Usage: gh soc unassign " - exit 1 - fi - unassign "$2" - ;; - log) - log - ;; - status) - status - ;; - delete) - delete - ;; - *) - echo -n "${ERR} Invalid argument. Available options: init, precheck, " >&2 - echo -n "sync, unsync, open, close, invite, disinvite, assign, " >&2 - echo "unassign, log, status, delete" >&2 - exit 1 - ;; - esac -} -main "$@" +# GitHub CLI extension entry point for SoC CLI. +# +# This file is named 'gh-soc' so that the GitHub CLI recognises it as the +# 'gh soc' extension. All logic is delegated to the 'soc' dispatcher which +# reads SOC_PLATFORM_CLI from system_config.sh and invokes the appropriate +# platform-specific command script (e.g. soc-gh-open). +# +# Usage (via gh extension): gh soc [args...] +# Usage (directly): soc [args...] + +exec "$(dirname "${BASH_SOURCE[0]}")/soc" "$@" diff --git a/soc b/soc new file mode 100755 index 0000000..8d87d91 --- /dev/null +++ b/soc @@ -0,0 +1,51 @@ +#!/usr/bin/env bash + +# soc – Platform-aware dispatcher for SoC CLI commands. +# +# Reads SOC_PLATFORM_CLI from system_config.sh (default: 'gh') and delegates +# to the matching platform-specific script: +# +# soc open → (SOC_PLATFORM_CLI=gh) → exec soc-gh-open +# soc open → (SOC_PLATFORM_CLI=tea) → exec soc-tea-open +# +# For commands that have no platform-specific variant the dispatcher falls back +# to a plain soc- script in the same directory. + +set +o errexit +set -o nounset + +CMD="${1:-}" +shift || true + +if [[ -z "${CMD}" ]]; then + echo "usage: soc [args...]" >&2 + exit 1 +fi + +# Determine the platform CLI. We parse system_config.sh with grep/sed instead +# of sourcing it to avoid readonly-variable conflicts when the config is later +# sourced inside the individual command scripts. +SOC_PLATFORM_CLI='gh' +if [[ -f 'system_config.sh' ]]; then + _val=$(grep "^readonly SOC_PLATFORM_CLI=" system_config.sh \ + | sed "s/^readonly SOC_PLATFORM_CLI='\(.*\)'/\1/" 2>/dev/null \ + || true) + if [[ -n "${_val:-}" ]]; then + SOC_PLATFORM_CLI="${_val}" + fi + unset _val +fi + +PLATFORM="${SOC_PLATFORM_CLI}" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Try platform-specific command first (e.g. soc-gh-open), then fall back to a +# platform-agnostic command (e.g. soc-open) if one exists. +if [[ -x "${SCRIPT_DIR}/soc-${PLATFORM}-${CMD}" ]]; then + exec "${SCRIPT_DIR}/soc-${PLATFORM}-${CMD}" "$@" +elif [[ -x "${SCRIPT_DIR}/soc-${CMD}" ]]; then + exec "${SCRIPT_DIR}/soc-${CMD}" "$@" +else + echo "soc: unknown command '${CMD}' for platform '${PLATFORM}'" >&2 + exit 1 +fi diff --git a/soc-gh-assign b/soc-gh-assign new file mode 100755 index 0000000..9ac7df0 --- /dev/null +++ b/soc-gh-assign @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +# soc-gh-assign: Assigns a repository to the team via GitHub CLI (gh). +# Usage: soc assign + +# shellcheck source=soc-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib.sh" +# shellcheck source=soc-lib-gh.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib-gh.sh" + +if [[ "$#" -ne 1 || -z "${1:-}" ]]; then + echo "${ERR} Invalid number of arguments or empty argument." >&2 + echo "${WRN} Usage: soc assign " + exit 1 +fi + +source_config_files +precheck 0 +check_if_gh_has_required_token_scopes + +assign "$1" diff --git a/soc-gh-close b/soc-gh-close new file mode 100755 index 0000000..1ef9fcc --- /dev/null +++ b/soc-gh-close @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# soc-gh-close: Closes a course (archives project data, optionally removes team) +# using GitHub CLI (gh). +# Usage: soc close + +# shellcheck source=soc-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib.sh" +# shellcheck source=soc-lib-gh.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib-gh.sh" + +source_config_files +precheck 0 +check_if_gh_has_required_token_scopes + +close diff --git a/soc-gh-delete b/soc-gh-delete new file mode 100755 index 0000000..5651094 --- /dev/null +++ b/soc-gh-delete @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# soc-gh-delete: Deletes the entire GitHub organization via GitHub CLI (gh). +# Usage: soc delete + +# shellcheck source=soc-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib.sh" +# shellcheck source=soc-lib-gh.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib-gh.sh" + +source_config_files +precheck 0 +check_if_gh_has_required_token_scopes + +delete diff --git a/soc-gh-disinvite b/soc-gh-disinvite new file mode 100755 index 0000000..21ef572 --- /dev/null +++ b/soc-gh-disinvite @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# soc-gh-disinvite: Cancels pending organization invitations via GitHub CLI (gh). +# Usage: soc disinvite + +# shellcheck source=soc-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib.sh" +# shellcheck source=soc-lib-gh.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib-gh.sh" + +source_config_files +precheck 0 +check_if_gh_has_required_token_scopes + +disinvite diff --git a/soc-gh-init b/soc-gh-init new file mode 100755 index 0000000..35b41a7 --- /dev/null +++ b/soc-gh-init @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +# soc-gh-init: Initializes soc configuration files for use with GitHub CLI (gh). +# Usage: soc init + +# shellcheck source=soc-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib.sh" +# shellcheck source=soc-lib-gh.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib-gh.sh" + +init diff --git a/soc-gh-invite b/soc-gh-invite new file mode 100755 index 0000000..6ccb05c --- /dev/null +++ b/soc-gh-invite @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# soc-gh-invite: Invites users to the organization team via GitHub CLI (gh). +# Usage: soc invite + +# shellcheck source=soc-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib.sh" +# shellcheck source=soc-lib-gh.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib-gh.sh" + +source_config_files +precheck 0 +check_if_gh_has_required_token_scopes + +invite diff --git a/soc-gh-log b/soc-gh-log new file mode 100755 index 0000000..0a08e55 --- /dev/null +++ b/soc-gh-log @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# soc-gh-log: Logs the current state of the project to a JSON file via GitHub CLI (gh). +# Usage: soc log + +# shellcheck source=soc-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib.sh" +# shellcheck source=soc-lib-gh.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib-gh.sh" + +source_config_files +precheck 0 +check_if_gh_has_required_token_scopes + +log diff --git a/soc-gh-open b/soc-gh-open new file mode 100755 index 0000000..ecedf74 --- /dev/null +++ b/soc-gh-open @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +# soc-gh-open: Opens a course (creates team, links project, configures repos) +# using GitHub CLI (gh). +# Usage: soc open + +# shellcheck source=soc-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib.sh" +# shellcheck source=soc-lib-gh.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib-gh.sh" + +source_config_files +precheck 0 +check_if_gh_has_required_token_scopes + +open diff --git a/soc-gh-precheck b/soc-gh-precheck new file mode 100755 index 0000000..a0d1596 --- /dev/null +++ b/soc-gh-precheck @@ -0,0 +1,14 @@ +#!/usr/bin/env bash + +# soc-gh-precheck: Validates configuration and required tools for GitHub CLI (gh). +# Usage: soc precheck + +# shellcheck source=soc-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib.sh" +# shellcheck source=soc-lib-gh.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib-gh.sh" + +source_config_files + +# Validate global variables with verbosity level 1 +precheck 1 diff --git a/soc-gh-status b/soc-gh-status new file mode 100755 index 0000000..ad0e088 --- /dev/null +++ b/soc-gh-status @@ -0,0 +1,15 @@ +#!/usr/bin/env bash + +# soc-gh-status: Lists repositories assigned to the team via GitHub CLI (gh). +# Usage: soc status + +# shellcheck source=soc-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib.sh" +# shellcheck source=soc-lib-gh.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib-gh.sh" + +source_config_files +precheck 0 +check_if_gh_has_required_token_scopes + +status diff --git a/soc-gh-sync b/soc-gh-sync new file mode 100755 index 0000000..153b4d1 --- /dev/null +++ b/soc-gh-sync @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +# soc-gh-sync: Creates project and/or repositories for a course using GitHub CLI (gh). +# Usage: soc sync [project|repos] + +# shellcheck source=soc-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib.sh" +# shellcheck source=soc-lib-gh.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib-gh.sh" + +source_config_files +precheck 0 +check_if_gh_has_required_token_scopes + +# With an optional argument, the user specifies the source with which they +# want to synchronize; argument values: 'project' or 'repos'. Without the +# argument both the project and the repos will be synchronized. +sync "${1:-}" diff --git a/soc-gh-unassign b/soc-gh-unassign new file mode 100755 index 0000000..c2af074 --- /dev/null +++ b/soc-gh-unassign @@ -0,0 +1,21 @@ +#!/usr/bin/env bash + +# soc-gh-unassign: Removes a repository from the team via GitHub CLI (gh). +# Usage: soc unassign + +# shellcheck source=soc-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib.sh" +# shellcheck source=soc-lib-gh.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib-gh.sh" + +if [[ "$#" -ne 1 || -z "${1:-}" ]]; then + echo "${ERR} Invalid number of arguments or empty argument." >&2 + echo "${WRN} Usage: soc unassign " + exit 1 +fi + +source_config_files +precheck 0 +check_if_gh_has_required_token_scopes + +unassign "$1" diff --git a/soc-gh-unsync b/soc-gh-unsync new file mode 100755 index 0000000..ff9586b --- /dev/null +++ b/soc-gh-unsync @@ -0,0 +1,18 @@ +#!/usr/bin/env bash + +# soc-gh-unsync: Removes project and/or repositories for a course using GitHub CLI (gh). +# Usage: soc unsync [project|repos] + +# shellcheck source=soc-lib.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib.sh" +# shellcheck source=soc-lib-gh.sh +. "$(dirname "${BASH_SOURCE[0]}")/soc-lib-gh.sh" + +source_config_files +precheck 0 +check_if_gh_has_required_token_scopes + +# With an optional argument, the user specifies the source from which they +# want to unsynchronize; argument values: 'project' or 'repos'. Without the +# argument both the project and the repos will be unsynchronized. +unsync "${1:-}" diff --git a/soc-lib-gh.sh b/soc-lib-gh.sh new file mode 100644 index 0000000..f4ca509 --- /dev/null +++ b/soc-lib-gh.sh @@ -0,0 +1,2396 @@ +#!/usr/bin/env bash + +# GitHub CLI (gh) specific functions and main soc command implementations. +# Requires soc-lib.sh to be sourced before this file. + +# Check if GitHub CLI (gh) is installed +check_if_gh_installed() { + if ! command -v gh &> /dev/null; then + echo -n "${ERR} GitHub CLI (gh) is not installed on your computer. " >&2 + echo "Install to continue: https://cli.github.com/" >&2 + return 1 + elif [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} 'GitHub CLI (gh)' is installed on your computer." + fi +} + +# Checks if the GitHub CLI is authenticated with the required token scopes. +# Aborts the script with an error message if required scopes are missing. +# Usage: check_if_gh_has_required_token_scopes +check_if_gh_has_required_token_scopes() { + + local -a token_scopes + local -a missing_scopes + local missing_scopes_csv + + # Fetch the current token scopes using 'gh', extract line with 'grep' and use + # 'sed' to parse the line + token_scopes=$(gh auth status | grep 'Token scopes' | sed 's/.*Token scopes: //') + missing_scopes=() + + # Check for each required scope + for scope in "${GH_CLI_TOKEN_SCOPES[@]}"; do + if [[ "${token_scopes}" != *"${scope}"* ]]; then + missing_scopes+=("${scope}") + fi + done + + # Inform the user about the status + if [[ "${#missing_scopes[@]}" -ne 0 ]]; then + echo "${ERR} Missing required token scopes:" >&2 + for scope in "${missing_scopes[@]}"; do + echo "- ${scope}" >&2 + done + + missing_scopes_csv=$(IFS=,; echo "${missing_scopes[*]}") + echo -n "${WRN} Run 'gh auth refresh -s ${missing_scopes_csv}' " + echo "to update the token." + + exit 1 + fi +} + +# Checks if repositories listed in the array in the config file exist. Aborts +# the script with an error message if any repository does not exist. Usage: +# check_if_repos_exist +check_if_repos_exist() { + + if [[ "$#" -lt 2 ]]; then + echo -n "${ERR} Insufficient arguments provided for the " >&2 + echo "'${FUNCNAME[0]}' function." >&2 + exit 1 + fi + + local repos_owner="$1" + shift # Remove the argument + local repos_name="$1" + shift # Remove the argument to treat the rest as an array + local -a repos=("$@") + + # Initialize a flag to track if any repository does not exist + local repo_noexists=0 + + for ((i=0; i<"${#repos[@]}"; i++)); do + # Check if the repository exists + if gh repo view "${repos_owner}/${repos[i]}" &> /dev/null; then + echo -n "${YUP} Repository ${repos_owner}/${repos[i]} " + echo "exists as expected." + else + echo "${ERR} Repository ${repos_owner}/${repos[i]} no exists." >&2 + repo_noexists=1 + fi + done + + # Check the flag and exit with an error if any repository does not exist + if [[ "${repo_noexists}" -eq 1 ]]; then + echo -n "${ERR} For proper functioning of '${FUNCNAME[1]}' command, " >&2 + echo -n "it is required that the repositories listed in the " >&2 + echo -n "'${repos_name}' array in the config file exist " >&2 + echo "on the '${repos_owner}' GitHub account." >&2 + exit 1 + else + echo -n "${OK} The check to see if the '${repos_name}' " + echo "repositories exist has been completed." + fi +} + +# Checks if repositories listed in the array in the config file do not already +# exist. Aborts the script with an warning message if any repository already +# exists. Usage: check_if_repos_noexist +check_if_repos_noexist() { + + local repos_owner="$1" + shift # Remove the argument + local repos_name="$1" + shift # Remove the argument to treat the rest as an array + local -a repos=("$@") + + # Initialize a flag to track if any repository already exists + local repo_exists=0 + + for ((i=0; i<"${#repos[@]}"; i++)); do + # Check if the repository exists + if ! gh repo view "${repos_owner}/${repos[i]}" &> /dev/null; then + echo -n "${YUP} Repository ${repos_owner}/${repos[i]} " + echo "not yet exists as expected." + else + echo "${WRN} Repository ${repos_owner}/${repos[i]} already exists." + repo_exists=1 + fi + done + + # Check the flag and exit with a warning if any repository already exists. The + # presence of repositories may be due to synchronizing repositories beforehand + # or already having own repositories with the same names as the source ones. + if [[ "${repo_exists}" -eq 1 ]]; then + echo -n "${WRN} One or more repositories listed in the '${repos_name}'" + echo -n " array in the config file already exist on the " + echo "'${repos_owner}' GitHub account." + exit 1 + else + echo -n "${OK} The check to see if the '${repos_name}' " + echo "repositories exist has been completed." + fi +} + +# Checks if repositories in source repo owner GitHub account and listed in the +# array in the config file are templates. Attempt to make them templates if they +# are not already. Aborts the script with an error message if any repository is +# not a template. Usage: ensure_repos_as_templates +# +ensure_repos_as_templates() { + + local repos_owner="$1" + shift # Remove the argument + local repos_name="$1" + shift # Remove the argument to treat the rest as an array + local -a repos=("$@") + + # Initialize a flag to track if any repository is not a template + local repo_is_not_template=0 + + for ((i=0; i<"${#repos[@]}"; i++)); do + # Check if the repository is a template + if [[ $(gh api repos/"${repos_owner}/${repos[i]}" --jq '.is_template') == true ]]; then + echo -n "${YUP} Repository ${repos_owner}/${repos[i]} " + echo "is a template as expected." + else + echo -n "${WRN} Repository ${repos_owner}/${repos[i]} is not a template. " + echo "Attempting to make it a template..." + # Attempt to convert the repository to a template + if gh api -X PATCH repos/"${repos_owner}/${repos[i]}" --field is_template=true --silent &> /dev/null; then + echo "${YUP} Successfully transformed into a template." + else + echo "${WRN} Failed to transform into a template." + repo_is_not_template=1 + fi + fi + done + + # Check the flag and exit with a error if any repository is not a template. + if [[ "${repo_is_not_template}" -eq 1 ]]; then + echo -n "${ERR} For proper functioning of '${FUNCNAME[1]}' command, " >&2 + echo -n "it is required that the repositories in '${repos_owner}' " >&2 + echo -n "GitHub account and listed in the '${repos_name}' array " >&2 + echo "in the config file are templates." >&2 + exit 1 + else + echo -n "${OK} The check to see if the '${repos_name}' repositories " + echo "are templates has been completed." + fi +} + +# Forks repositories from SOURCE_REPOS_OWNER to GH_ORG_NAME without cloning +# them. +# Usage: fork_repositories +fork_repositories() { + + for ((i=0; i<"${#SOURCE_REPOS[@]}"; i++)); do + + # Fork the repository to the organization without cloning it locally. + gh repo fork "${SOURCE_REPOS_OWNER}"/"${SOURCE_REPOS[i]}" \ + --org "${GH_ORG_NAME}" \ + --fork-name "${TARGET_REPOS[i]}" \ + --clone=false + + if [[ $? -ne 0 ]]; then + echo -n "${ERR} An error occurred while forking the " >&2 + echo -n "${SOURCE_REPOS_OWNER}/${SOURCE_REPOS[i]} repository to " >&2 + echo "${GH_ORG_NAME}/${TARGET_REPOS[i]}" >&2 + exit 1 + fi + done + echo "${OK} Forking repositories has been completed" +} + +# Create repositories in GH_ORG_NAME from SOURCE_REPOS_OWNER templates. Usage: +# create_private_repos_from_templates +create_private_repos_from_templates() { + + for ((i=0; i<"${#SOURCE_REPOS[@]}"; i++)); do + + # Create a repository in an organization from a template. + gh repo create "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}" \ + --template "${SOURCE_REPOS_OWNER}"/"${SOURCE_REPOS[i]}" \ + --private=true + + if [[ $? -ne 0 ]]; then + echo -n "${ERR} An error occurred while creating the " >&2 + echo -n "${GH_ORG_NAME}/${TARGET_REPOS[i]} repository from " >&2 + echo "the ${SOURCE_REPOS_OWNER}/${SOURCE_REPOS[i]} template" >&2 + exit 1 + fi + done + echo "${OK} Creating repositories has been completed" +} + +# Delete repositories listed in TARGET_REPOS array in user_config.sh file from +# GH_ORG_NAME. Usage: delete_repositories +delete_repositories() { + + for ((i=0; i<"${#TARGET_REPOS[@]}"; i++)); do + + # deleting a repo + gh repo delete "$GH_ORG_NAME"/"${TARGET_REPOS[i]}" --yes + + if [[ $? -ne 0 ]]; then + echo -n "${ERR} An error occurred while deleting the repository" >&2 + echo "$GH_ORG_NAME"/"${TARGET_REPOS[i]}" >&2 + exit 1 + fi + done + + echo "${OK} Deletion of the repositories has been completed." +} + +# Sets specified repositories in GH_ORG_NAME as private and marks them as +# templates. +# Usage: set_repo_as_private_template +set_repo_as_private_template() { + + for ((i=0; i<"${#TARGET_REPOS[@]}"; i++)); do + + gh repo edit "$GH_ORG_NAME"/"${TARGET_REPOS[i]}" \ + --visibility private \ + --template=true + + if [[ $? -ne 0 ]]; then + echo -n "${ERR} An error occurred while setting the " >&2 + echo "$GH_ORG_NAME/${TARGET_REPOS[i]} repository as a private template" >&2 + exit 1 + fi + done + echo "${OK} Setting repositories as private templates has been completed" +} + +check_repo_gh_pages_enabled() { + # Local variable declaration for return value + local has_pages + + has_pages=$(gh api \ + --method GET \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[0]}" \ + --jq '.has_pages') + + if [[ $? -eq 0 ]]; then + echo "${has_pages}" + else + echo -n "${ERR} An error occurred while checking if GitHub Pages " + echo "is enabled for '${GH_ORG_NAME}/${TARGET_REPOS[0]}'." + exit 1 + fi +} + +# Enables GitHub Pages on the first target repository for the specified branch. +# Usage: switch_on_gh_pages +swich_on_gh_pages() { + # Local variable declaration for the return value + local has_pages + + has_pages=$(check_repo_gh_pages_enabled) + if [[ $? -eq 0 ]]; then + if [[ "${has_pages}" == true ]]; then + echo -n "${WRN} GitHub Pages for ${GH_ORG_NAME}/${TARGET_REPOS[0]} " + echo "is already enabled" + return 0 + fi + else + echo "${has_pages}" >&2 + exit 1 + fi + + echo '{"source":{"branch":"'"${GH_REPO_BRANCH}"'","path":"/"}}' | gh api \ + --method POST \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + /repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[0]}"/pages \ + --silent \ + --input - + + if [[ $? -eq 0 ]]; then + echo -n "${OK} GitHub Pages has been enabled on the '${GH_REPO_BRANCH}' " + echo "branch in the ${GH_ORG_NAME}/${TARGET_REPOS[0]} repository." + echo -n "${WRN} Please be patient: the creation process for the GitHub " + echo -n "page has started, but the page itself will be available " + echo "in about 3 minutes." + else + echo -n "${ERR} An error occurred while enabling the '${GH_REPO_BRANCH}' " >&2 + echo "branch in the ${GH_ORG_NAME}/${TARGET_REPOS[0]}" >&2 + exit 1 + fi +} + +# Sets website links for the target repositories in the GitHub organization. +# Usage: set_repos_website +set_repos_website() { + + # Iterate through repositories + for ((i=0; i<"${#TARGET_REPOS[@]}"; i++)); do + + # Setting website link in the target repositories" + gh repo edit "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}" \ + --homepage https://"${GH_ORG_NAME}".github.io/"${TARGET_REPOS[0]}"/ + + if [[ $? -ne 0 ]]; then + echo -n "${ERR} An error occurred while setting a website link in the " >&2 + echo "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}} repository" >&2 + exit 1 + fi + done + echo "$OK Setting the website links has been completed" +} + +# Sets descriptions for the target repositories in the GitHub organization. +# Usage: set_repos_descriptions +set_repos_descriptions() { + + # Iterate through repositories + for ((i=0; i<"${#TARGET_REPOS[@]}"; i++)); do + + # Setting website link in the target repositories" + gh repo edit "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}" \ + --description "${TARGET_REPO_DESCRIPTION}" + + if [[ $? -ne 0 ]]; then + echo -n "${ERR} An error occurred while setting a website link in the " >&2 + echo "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}} repository" >&2 + exit 1 + fi + done + echo "$OK Setting the website links has been completed" +} + +# Retrieves the SHA for a file in the first target repository. +get_file_sha() { + + # Check if the correct number of non-empty arguments is passed + if [[ "$#" -ne 1 || -z "$1" ]]; then + echo "${ERR} Invalid number of arguments or empty argument." >&2 + echo "${WRN} Usage: ${FUNCNAME[0]} " + exit 1 + fi + + # Local variable declaration for the return value + local sha + + # Get the file SHA + sha=$(gh api \ + --method GET \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[0]}"/contents/"${source_file}" \ + --jq '.sha') + + if [[ $? -eq 0 ]]; then + if [[ -n "${sha}" ]]; then + echo "${sha}" + else + echo "${ERR} SHA for the '${source_file}' is emapty" + exit 1 + fi + else + echo "${ERR} An error occurred while obtaining SHA for the '${source_file}'" + exit 1 + fi +} + +# Downloads the contents of a specified file from a repository to a temporary +# file. +download_repo_file_contents_to_tmp_file() { + + # Check if the correct number of non-empty arguments is passed + if [[ "$#" -ne 2 || -z "$1" || -z "$2" ]]; then + echo "${ERR} Invalid number of arguments or empty argument." >&2 + echo "${WRN} Usage: ${FUNCNAME[0]} " + exit 1 + fi + + local source_file="$1" + local tmp_file="$2" + + gh api \ + --method GET \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[0]}"/contents/"${source_file}" \ + --jq '.content' | base64 --decode > "${tmp_file}" + + # Check whether the operation was successful + if [[ $? -eq 0 ]]; then + echo -n "${YUP} The content of the '${source_file}' has been written " + echo "to the '${tmp_file}'" + else + echo -n "${ERR} An error occurred while downloading '${source_file}' " >&2 + echo "or writing its content to the '${tmp_file}'" >&2 + exit 1 + fi +} + +# Updates the contents of a temporary file by replacing specific text. +# It checks for the presence of SOURCE_REPOS_OWNER and then replaces it with GH_ORG_NAME. +# Additionally, it iterates through repositories to replace other specified text. +update_tmp_file() { + + # Check if the correct number of non-empty arguments is passed + if [[ "$#" -ne 1 || -z "$1" ]]; then + echo "${ERR} Invalid number of arguments or empty argument." >&2 + echo "${WRN} Usage: ${FUNCNAME[0]} " + exit 1 + fi + + local tmp_file="$1" + + if grep -q "${SOURCE_REPOS_OWNER}" "${tmp_file}"; then + # Perform the replacement in the temporary file + sed -i "s/${SOURCE_REPOS_OWNER}/${GH_ORG_NAME}/g" "${tmp_file}" + + if [[ $? -ne 0 ]]; then + echo -n "${ERR} An error occurred while updating the " >&2 + echo "temporary file: ${tmp_file}" >&2 + exit 1 + fi + else + echo -n "${WRN} Text to be replaced '${SOURCE_REPOS_OWNER}' " + echo "was not found in the file: '${tmp_file}'." + fi + + + # Iterate through repositories + for ((i=1; i<"${#SOURCE_REPOS[@]}"; i++)); do + + local word_to_replace="${SOURCE_REPOS[i]}" + local new_word="${TARGET_REPOS[i]}" + + if grep -q "${word_to_replace}" "${tmp_file}"; then + # Perform the replacement in the temporary file + sed -i "s/${word_to_replace}/${new_word}/g" "${tmp_file}" + + if [[ $? -ne 0 ]]; then + echo -n "${ERR} An error occurred while updating the " >&2 + echo "temporary file: '${tmp_file}'" >&2 + exit 1 + fi + else + echo -n "${WRN} Text to be replaced '${word_to_replace}' " + echo "was not found in the file: '${tmp_file}'." + fi + done +} + +# Updates the contents of a file in a repository by uploading a new version. +# This function uses a temporary file containing the new content, the SHA of the +# existing file, and the file path to update the file in the repository. +update_repo_file_contents() { + + # Check if the correct number of non-empty arguments is passed + if [[ "$#" -ne 3 || -z "$1" || -z "$2" || -z "$3" ]]; then + echo "${ERR} Invalid number of arguments or empty argument." >&2 + echo "${WRN} Usage: ${FUNCNAME[0]} " + exit 1 + fi + + local tmp_file="$1" + local file_sha="$2" + local source_file="$3" + + gh api \ + --method PUT \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[0]}"/contents/"${source_file}" \ + --raw-field "message=update ${source_file}" \ + --raw-field "content=$(base64 < ${tmp_file})" \ + --raw-field "sha=${file_sha}" \ + --silent + + # Check whether the operation was successful + if [[ $? -eq 0 ]]; then + echo "${YUP} The ${source_file} has been updated" + else + echo "${ERR} An error occurred while updating the '${source_file}'" >&2 + exit 1 + fi +} + +# Updates repository files without cloning the entire repository. +# This function iterates through a list of files, performing the following steps +# for each: +# 1. Creates a temporary file to work with the file's content. +# 2. Retrieves the SHA of the current file in the repository to allow for its update. +# 3. Downloads the file content from the repository to the temporary file. +# 4. Applies changes to the content within the temporary file. +# 5. Uploads the updated content back to the repository, replacing the old file. +# Usage: update_repo_without_cloning +update_repo_without_cloning() { + + local file_sha + local tmp_file + + # Generate two temporary files and get sha of each source file + for source_file in "${SOURCE_MAIN_REPO_INFO_FILES[@]}"; do + + # Create a temporary file + tmp_file="$(mktemp)" + if [[ $? -ne 0 ]]; then + echo "${ERR} Unable to create a temporary file" >&2 + exit 1 + else + echo "${YUP} Temporary file '${tmp_file}' has been created" + fi + + # Remove the temporary file if the script exits unexpectedly. This ensures that even if the + # script fails or is interrupted, the temporary file does not remain on disk. + # NOTE: ShellCheck may warn about SC2064 here (variable expansion at trap assignment), but this + # is intentional. + trap "remove_tmp_file ${tmp_file}" EXIT + + # Get the file SHA + file_sha=$(get_file_sha "${source_file}") + if [[ $? -eq 0 ]]; then + echo "${YUP} The SHA for the '${source_file}' file has been obtained" + else + echo "${file_sha}" >&2 + exit 1 + fi + + # Download the file contents to the temporary file + download_repo_file_contents_to_tmp_file "${source_file}" "${tmp_file}" + # Update the temporary file + update_tmp_file "${tmp_file}" + # Update the repository file contents + update_repo_file_contents "${tmp_file}" "${file_sha}" "${source_file}" + + # Remove the temporary file + if [[ -f "${tmp_file}" ]]; then + rm -f "${tmp_file}" + echo "${YUP} Temporary file ${tmp_file} has been deleted" + echo + fi + done + echo "${OK} The repo '${TARGET_REPOS[0]}' has been updated" +} + +# Updates the default repository permission setting for the GitHub organization. +# This function sets the default permission that new repositories will have when +# created within the organization. It uses the GitHub API to patch the +# organization's settings. +# Usage: update_org_default_repo_permission +update_org_default_repo_permission() { + + local repo_permission="${GH_ORG_DEFAULT_REPOSITORY_PERMISSION}" + + # Update the organization settings + gh api \ + --method PATCH \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + /orgs/"${GH_ORG_NAME}" \ + --raw-field default_repository_permission="${repo_permission}" \ + --silent + + ## Check whether the operation was successful + if [[ $? -eq 0 ]]; then + echo -n "${OK} The '${GH_ORG_NAME}' organization default repo permission " + echo "setting has been updated with '${repo_permission}' value" + else + echo -n "${ERR} An error occurred while updating the '${GH_ORG_NAME}' " >&2 + echo -n "organization default repo permission setting with " >&2 + echo "'${repo_permission}' value" >&2 + exit 1 + fi +} + +# Updates the organization setting to allow or disallow members from forking +# private repositories. This function configures whether members of the GitHub +# organization can fork private repositories. It performs a PATCH request to +# the GitHub API to update the organization's settings accordingly. +# Usage: update_org_members_can_fork_private_repo +update_org_members_can_fork_private_repo() { + + local fork_private="${GH_ORG_MEMBERS_CAN_FORK_PRIVATE_REPOSITORIES}" + + # Update the organization settings + gh api \ + --method PATCH \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + /orgs/"${GH_ORG_NAME}" \ + --field members_can_fork_private_repositories="${fork_private}" \ + --silent + + ## Check whether the operation was successful + if [[ $? -eq 0 ]]; then + echo -n "${OK} The ${GH_ORG_NAME} organization 'members can fork private repo'" + echo "setting has been updated with '${fork_private}' value" + else + echo -n "${ERR} An error occurred while updating the '${GH_ORG_NAME}' " >&2 + echo -n "organization 'members can fork private repo' setting with " >&2 + echo "'${fork_private}' value" >&2 + exit 1 + fi +} + +# Creates a new team within the GitHub organization. This function uses the +# GitHub API to create a team with the specified details. It requires the +# organization name, team name, description, notification setting, and privacy +# level. +# Usage: create_team +create_team() { + # Local variable declaration for the return value + local team_id + + # Retrieve the team ID + team_id=$(get_team_id) + + if [[ $? -eq 0 ]]; then + if [[ -n "${team_id}" ]]; then + echo -n "${WRN} The '${GH_TEAM_NAME}' team already exists " + echo "in the '${GH_ORG_NAME}' organization." + return 0 + fi + else + echo "${team_id}" >&2 + exit 1 + fi + + # Creating the team + gh api \ + --method POST \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + /orgs/"${GH_ORG_NAME}"/teams \ + --raw-field name="${GH_TEAM_NAME}" \ + --raw-field description="${GH_TEAM_DESCRIPTION}" \ + --raw-field notification_setting="${GH_TEAM_NOTIFICATIONS}" \ + --raw-field privacy="${GH_TEAM_PRIVACY}" \ + --silent + + # Checking if the operation was successful + if [[ $? -eq 0 ]]; then + echo -n "${OK} The team '${GH_TEAM_NAME}' has been created " + echo "in the organization '${GH_ORG_NAME}'." + else + echo "${ERR} An error occurred while creating the team '${GH_TEAM_NAME}'." >&2 + exit 1 + fi +} + +# Creates a new project in the GitHub organization from a specified template. +# It's intended to copy a project from another source into the target +# organization under a new title. Usage: create_project_from_template +create_project_from_template() { + + local project_number + + project_number=$(get_project_number) + if [[ $? -eq 0 ]]; then + if [[ -n "${project_number}" ]]; then + echo -n "${WRN} Project with the '${TARGET_PROJECT_TITLE}' title " + echo "already exists in the '${GH_ORG_NAME}' organization." + return 0 + fi + else + echo "${project_number}" >&2 + exit 1 + fi + + gh project copy "${SOURCE_PROJECT_NUMBER}" \ + --source-owner "${SOURCE_PROJECT_OWNER}" \ + --target-owner "${GH_ORG_NAME}" \ + --title "${TARGET_PROJECT_TITLE}" + + if [[ $? -eq 0 ]]; then + echo "${OK} The project '${TARGET_PROJECT_TITLE}' has been created." + else + echo -n "${ERR} An error occurred while copying the project " >&2 + echo "from ${SOURCE_PROJECT_OWNER} to '${GH_ORG_NAME}'." >&2 + exit 1 + fi +} + +# Clones the specified repositories from the GitHub organization to the local +# machine. Iterates through the list of target repositories and clones each one +# using the 'gh repo clone' command. +# Usage: clone_repositories +clone_repositories() { + # Iterate through repositories + for ((i=0; i<"${#TARGET_REPOS[@]}"; i++)); do + + # Check if the repository directory already exists + if [[ -d "${TARGET_REPOS[i]}" ]]; then + echo -n "${WRN} Skipping cloning ${TARGET_REPOS[i]} because repository " + echo "directory already exists." + continue # Skip to the next repository + fi + + gh repo clone "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}" + + if [[ $? -ne 0 ]]; then + echo -n "${ERR} An error occurred while cloning the " >&2 + echo "${GH_ORG_NAME}/${TARGET_REPOS[i]} repository" >&2 + exit 1 + fi + done + echo "${OK} Cloning repositories has been completed" +} + +# Sets a specific team as the CODEOWNERS for all targeted repositories. This +# function iterates through the list of repositories, excluding the first one, +# and updates the CODEOWNERS file to reflect the designated team. It checks if +# the CODEOWNERS file is writable before attempting to update it. +# Usage: set_team_as_codeowners +set_team_as_codeowners() { + + # Iterate through repositories, starting from the second one because the first + # one does not have a 'CODEOWNERS' file + for ((i=1; i<"${#TARGET_REPOS[@]}"; i++)); do + + local file_path="${TARGET_REPOS[i]}/.github/CODEOWNERS" + + if [[ ! -w "${file_path}" ]]; then + # Because we have 'exit 1' here, the trap won't be activated. + echo -n "${ERR} The file ${file_path} does not exist " >&2 + echo "or is not writable." >&2 + exit 1 + fi + + # Overwrite the first line in the CODEOWNERS file + echo "* @${GH_ORG_NAME}/${GH_TEAM_NAME}" > "${file_path}" + + echo -n "${YUP} The CODEOWNERS file in '${TARGET_REPOS[i]}' " + echo "has been updated to ${GH_ORG_NAME}/${GH_TEAM_NAME}." + done + echo "${OK} The configuration of the 'CODEOWNERS' files has been completed." +} + +# Commits changes in local repositories and pushes them to their remote +# counterparts. This function iterates through the repositories, excluding the +# first one, to commit any changes made, specifically after updating CODEOWNERS +# or similar files, and then pushes these changes to the remote repository. It +# uses a custom 'git_with_dir' function to specify the git directory and work +# tree for each operation. Usage: commit_changes_and_push_to_remote_repos +commit_changes_and_push_to_remote_repos() { + # Set the commit message + local commit_message="Assign ${GH_TEAM_NAME} as the code owners." + + # Function to execute git commands with specified git dir and work tree + git_with_dir() { + git --git-dir="$1/.git" --work-tree="$1" "${@:2}" + } + + # Iterate through repositories, starting from the second one because in the first + # one we do not change any file. + for ((i=1; i<"${#TARGET_REPOS[@]}"; i++)); do + local target_repo="${TARGET_REPOS[i]}" + + if [[ ! -d "${target_repo}" ]]; then + # Because we have 'exit 1' here, the trap won't be activated. + echo -n "${ERR} The directory ${target_repo} does not exist." >&2 + echo "Clone repositories first." >&2 + exit 1 + fi + + # Add changes to the staging area + git_with_dir "${target_repo}" add . + + # Check if there are changes to commit + if git_with_dir "${target_repo}" diff --staged --quiet; then + echo -n "${WRN} The ${target_repo} repository doesn't have " + echo "any changes to commit." + else + # Commit the changes + git_with_dir "${target_repo}" commit -m "${commit_message}" + + # Push the changes to the remote repository + git_with_dir "${target_repo}" push origin "${GH_REPO_BRANCH}" + + echo -n "${YUP} Changes made in the ${target_repo} directory " + echo "have been pushed to the remote repository." + fi + done + echo "${OK} Updating remote repositories has been completed." +} + +# Applies branch protection rules to lock branches for specified repositories +# within a GitHub organization. It iterates over the repositories, starting +# from the second, to apply a "lock branch" rule, which prevents direct pushes +# to the specified branches, requiring pull requests for changes. +# Usage: lock_branch +lock_branch() { + # Iterate through the repositories, starting from the second one because the + # first one does not contain assignments for students and so does not need the + # branch to be locked + for ((i=1; i<"${#TARGET_REPOS[@]}"; i++)); do + + # Branch protection rule setup: lock branch + gh api \ + --method PUT \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[i]}"/branches/"${GH_REPO_BRANCH}"/protection \ + --field required_status_checks='null' \ + --field enforce_admins='null' \ + --field required_pull_request_reviews='null' \ + --field restrictions='null' \ + --field lock_branch='true' \ + --silent + + # Check whether the operation was completed + if [[ $? -eq 0 ]]; then + echo -n "${YUP} The branch protection rule 'lock branch' " + echo -n "for the '${GH_REPO_BRANCH}' branch on the repository " + echo "${GH_ORG_NAME}/${TARGET_REPOS[i]} has been set." + else + echo "${ERR} An error occurred while setting branch protection rule." >&2 + exit 1 + fi + done + echo "${OK} Branch protection rule 'lock branch ' setup completed." +} + +# Enforces a branch protection rule requiring conversation resolution before +# merging pull requests. This function iterates through the repositories, +# excluding the first, to apply this specific rule. It ensures all discussions +# are resolved before allowing merges, enhancing collaboration quality. +# Usage: require_conversation_resolution_before_merging +require_conversation_resolution_before_merging() { + # Iterate through the repositories, starting from the second one because the + # first one does not contain assignments for students and so does not need the + # branch rule to be set to 'require conversation resolution before merging'. + for ((i=1; i<"${#TARGET_REPOS[@]}"; i++)); do + + # Branch protection rule setup: Require conversation resolution before merging + gh api \ + --method PUT \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + repos/"${GH_ORG_NAME}"/"${TARGET_REPOS[i]}"/branches/"${GH_REPO_BRANCH}"/protection \ + --field required_status_checks='null' \ + --field enforce_admins='null' \ + --field required_pull_request_reviews='null' \ + --field restrictions='null' \ + --field required_conversation_resolution='true' \ + --silent + + # Check whether the operation was successful + if [[ $? -eq 0 ]]; then + echo -n "${YUP} The branch protection rule " + echo -n "'Require conversation resolution before merging' for the " + echo -n "'${GH_REPO_BRANCH}' branch on the repository " + echo "${GH_ORG_NAME}/${TARGET_REPOS[i]} has been set." + else + echo -n "${ERR} An error occurred while setting up " >&2 + echo "the branch protection rule." >&2 + exit 1 + fi + done + echo -n "${OK} Branch protection rule " + echo "'Require conversation resolution before merging' setup completed." +} + + +# Retrieves the unique ID of a project by its title within a GitHub +# organization. This ID is essential for operations that modify project +# settings or link the project to other entities. The function lists all +# projects under the specified organization and filters by title to find the +# correct project ID. Usage: get_project_id +get_project_id() { + # Local variable declaration for the return value + local project_id + + # List projects under a specific organization and extract the project ID using + # jq based on the title. + project_id=$(gh project list \ + --owner "${GH_ORG_NAME}" \ + --format 'json' \ + --jq ".projects[] | select(.title == \"${TARGET_PROJECT_TITLE}\") | .id") + + if [[ $? -eq 0 ]]; then + echo "${project_id}" + else + echo -n "${ERR} An error occurred while obtaining the ID of the " + echo -n "'${TARGET_PROJECT_TITLE}' project from the '${GH_ORG_NAME}' " + echo "organization." + exit 1 + fi +} + +# Retrieves the unique ID of a team by its name within a GitHub organization. +# This ID is used for assigning permissions or linking the team to projects and +# repositories. The function uses a GraphQL query to fetch the team ID based on +# the provided team name. +# Usage: get_team_id +get_team_id() { + # Local variable declaration for the return value + local team_id + + # Define a GraphQL query to retrieve the team ID from the GitHub API. + local query=" + { + organization(login: \"${GH_ORG_NAME}\") { + team(slug: \"${GH_TEAM_NAME}\") { + id + } + } + } + " + + # Execute the GraphQL query and extract the team ID using + # jq. + team_id=$(gh api graphql \ + --raw-field query="${query}" \ + --jq '.data.organization.team.id') + + if [[ $? -eq 0 ]]; then + echo "${team_id}" + else + echo -n "${ERR} An error occurred while obtaining the ID of the " + echo "'${GH_TEAM_NAME}' team from the '${GH_ORG_NAME}' organization" + exit 1 + fi +} + +# Links a specific project to a specific team within a GitHub organization. +# This association allows for refined permission management and project access +# control. The function retrieves both the project ID and team ID, then uses a +# GraphQL mutation to create the link. Usage: link_project_to_team +link_project_to_team() { + # Local variable declarations + local project_id + local team_id + + # Retrieve the project ID. + project_id=$(get_project_id) + + if [[ $? -eq 0 ]]; then + if [[ -n "${project_id}" ]]; then + echo -n "${YUP} Obtaining the ID of the '${TARGET_PROJECT_TITLE}' project" + echo " has been completed" + else + echo -n "${WRN} The obtained ID of the '${TARGET_PROJECT_TITLE}' project " + echo -n "is empty: the '${GH_ORG_NAME}' organization has no such project. " + echo -n "Check the 'user_config' file to see if you have entered " + echo "the 'GH_ORG_NAME' and 'TARGET_PROJECT_TITLE' correctly." + exit 1 + fi + else + echo "${project_id}" >&2 + exit 1 + fi + + # Retrieve the team ID + team_id=$(get_team_id) + + if [[ $? -eq 0 ]]; then + if [[ -n "${team_id}" ]]; then + echo -n "${YUP} Obtaining the ID of the '${GH_TEAM_NAME}' team" + echo " has been completed" + else + echo -n "${ERR} The obtained ID of the '${GH_TEAM_NAME}' team " >&2 + echo -n "is empty: the '${GH_ORG_NAME}' organization has no such team. " >&2 + echo -n "Check the 'user_config' file to see if you have entered " >&2 + echo "the 'GH_ORG_NAME' and 'GH_TEAM_NAME' correctly." >&2 + exit 1 + fi + else + echo "${team_id}" >&2 + exit 1 + fi + + # Define a GraphQL mutation to link the project to the team. + local query=" + mutation { + linkProjectV2ToTeam(input: { + teamId: \"${team_id}\", + projectId: \"${project_id}\", + }) { + clientMutationId + } + } + " + + # Execute the GraphQL mutation using the GitHub CLI. + gh api graphql \ + --raw-field query="${query}" \ + --silent + + # Check the exit status of the last command to verify if the link operation + # was successful. + if [[ $? -eq 0 ]]; then + echo -n "${OK} The '${TARGET_PROJECT_TITLE}' project has been linked " + echo "to the '${GH_TEAM_NAME}' team" + else + echo -n "${ERR} An error occurred while linking the " >&2 + echo "'${TARGET_PROJECT_TITLE}' project to the '${GH_TEAM_NAME}' team" >&2 + exit 1 + fi +} + +# Fetches the unique ID of a GitHub user based on their login. This function is +# crucial for operations that require user identification, such as assigning +# roles or permissions. It verifies the provided argument and utilizes a +# GraphQL query to retrieve the user ID. +get_user_id() { + # Check if the correct number of non-empty arguments is passed + if [[ "$#" -ne 1 || -z "$1" ]]; then + echo "${ERR} Invalid number of arguments or empty argument." >&2 + echo "${WRN} Usage: ${FUNCNAME[0]} " + exit 1 + fi + + local gh_login="$1" + + # Local variable declaration for the return value + local user_id + + local query=" + { + user(login: \"${gh_login}\") { + id + } + } + " + + user_id=$(gh api graphql \ + --raw-field query="${query}" \ + --jq '.data.user.id') + + if [[ $? -eq 0 ]]; then + if [[ -n "${user_id}" ]]; then + echo "${user_id}" + else + echo -n "${ERR} The obtained ID of the '${gh_login}' user " + echo -n "is empty: the '${gh_login}' user does not exist. " + echo -n "Check the 'user_config' file to see if you have entered " + echo "the 'GH_TEAM_MEMBERS_EXCLUDED_FROM_REVIEWING' correctly." + exit 1 + fi + else + echo -n "${ERR} An error occurred while obtaining the ID " + echo "of the '${gh_login}' user." + exit 1 + fi +} + +# Enables automatic review assignment for a specified team within a GitHub +# organization. Iterates through GitHub user logins provided to exclude specific +# team members from automatic review assignments. Utilizes GraphQL to update +# team review assignment settings, including excluded members, algorithm, and +# notification preferences. +# Usage: enable_team_review_assignment +enable_team_review_assignment() { + # Local variable declaration + local github_logins=("${GH_TEAM_MEMBERS_EXCLUDED_FROM_REVIEWING[@]}") + local team_id + local -a user_ids # the array of GitHub user IDs + local fetched_user_id + local users_id_string + + team_id=$(get_team_id) + + if [[ $? -eq 0 ]]; then + if [[ -n "${team_id}" ]]; then + echo -n "${YUP} Obtaining the ID of the '${GH_TEAM_NAME}' team" + echo " has been completed" + else + echo -n "${ERR} The obtained ID of the '${GH_TEAM_NAME}' team " >&2 + echo -n "is empty: the '${GH_ORG_NAME}' organization has no such team. " >&2 + echo -n "Check the 'user_config' file to see if you have entered " >&2 + echo "the 'GH_ORG_NAME' and 'GH_TEAM_NAME' correctly." >&2 + exit 1 + fi + else + echo "${team_id}" >&2 + exit 1 + fi + + # Loop through all logins and execute a GraphQL query for each of them + for gh_login in "${github_logins[@]}"; do + fetched_user_id=$(get_user_id "${gh_login}") + if [[ $? -eq 0 ]]; then + user_ids+=("\"$fetched_user_id\"") # Adding ID as a string to the array + echo -n "${YUP} Obtaining the ID of the '${gh_login}' " + echo "user has been completed" + else + echo "${fetched_user_id}" >&2 + exit 1 + fi + done + + # TODO: + # add checking that the user belongs to the organization + # END: + + # Add comma between user IDs + users_id_string=$(IFS=,; echo "${user_ids[*]}") + + # Define the GraphQL mutation for updating team review assignment settings. + local query=" + mutation { + updateTeamReviewAssignment(input: { + algorithm: ${GH_TEAM_REVIEW_ASSIGNMENT_ALGORITHM}, + enabled: true, + excludedTeamMemberIds: [${users_id_string}], + id: \"${team_id}\", + notifyTeam: ${GH_TEAM_NOTIFY}, + teamMemberCount: ${GH_TEAM_MEMBER_COUNT}, + }) { + clientMutationId + } + } + " + + # Execute the GraphQL mutation + gh api graphql \ + --header "Accept: application/vnd.github.stone-crop-preview+json" \ + --raw-field query="${query}" \ + --silent + + # Check whether the operation was successful + if [[ $? -eq 0 ]]; then + echo -n "${OK} The review assignment has benn enabled " + echo "for the '${GH_TEAM_NAME}' team" + else + echo -n "${ERR} An error occurred while enabling the review " >&2 + echo "assignment for the '${GH_TEAM_NAME}' team" >&2 + exit 1 + fi +} + +# Retrieves the numeric ID of a team within a GitHub organization. This ID is +# essential for certain API calls that require team identification. The script +# uses the GitHub CLI to query the GitHub API and extract the team's numeric ID. +# Usage: get_team_number +get_team_number() { + # Local variable declaration for the return value + local team_number + + # Retrieve the team number + team_number=$(gh api \ + --method GET \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}" \ + --jq '.id') + + if [[ $? -eq 0 ]]; then + echo "${team_number}" + else + echo -n "${ERR} An error occurred while obtaining the number of the " + echo "'${GH_TEAM_NAME}' team from the '${GH_ORG_NAME}' organization." + exit 1 + fi +} + +# Invites users to a specified team within a GitHub organization based on email +# addresses. This script reads email addresses from a file named 'emails.txt', +# constructs and sends invitation requests for each. +# Usage: invite_to_organization_team +invite_to_organization_team() { + # Check if the file with emails exists and is readable + if [[ ! -r 'emails.txt' ]]; then + echo "${ERR} The file 'emails.txt' is not readable or does not exist." >&2 + exit 1 + fi + + # Local variable declaration + local team_number + local response + local http_status + local email + + # Get the team ID + team_number=$(get_team_number) + if [[ $? -eq 0 ]]; then + echo -n "${YUP} Obtaining the ID of the '${GH_TEAM_NAME}' team" + echo " has been completed" + else + echo "${team_number}" >&2 + exit 1 + fi + + # Reading from a file + while IFS= read -r email; do + + # Sending an invitation + response=$(gh api \ + --method POST \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + /orgs/"${GH_ORG_NAME}"/invitations \ + --raw-field email="${email}" \ + --raw-field role="${GH_ORG_ROLE}" \ + --field "team_ids[]=${team_number}" \ + --include) + + # Check whether the operation was successful + if [[ $? -eq 0 ]]; then + echo "${YUP} Invitation to join the '${GH_ORG_NAME}' sent to: ${email}." + else + # Extract the HTTP status code from the first line of the 'response' and and + # look for '422' + http_status=$(echo "${response}" | grep --max-count=1 -o '422') + + if [[ "${http_status}" -eq 422 ]]; then + echo -n "${WRN} The invitation to '${email}' has not been sent. The email " + echo -n "address is not valid, or the variable 'GH_ORG_ROLE' from " + echo -n "'system_config.sh' is not valid, or the user with this email may " + echo -n "already be a member of the '${GH_ORG_NAME}' organization. If the " + echo -n "latter, add the user to the '${GH_TEAM_NAME}' team manually or send " + echo -n "the link https://github.com/orgs/${GH_ORG_NAME}/teams/${GH_TEAM_NAME}" + echo " to the user so he/she can request to join the team." + else + echo -n "${ERR} Failed to send the '${GH_ORG_NAME}' invitation " >&2 + echo "to: ${email}" >&2 + exit 1 + fi + fi + done < 'emails.txt' +} + +get_invitation_id_by_email() { + # Check if the correct number of non-empty arguments is passed + if [[ "$#" -ne 1 || -z "$1" ]]; then + echo "${ERR} Invalid number of arguments or empty argument." >&2 + echo "${WRN} Usage: ${FUNCNAME[0]} " + exit 1 + fi + + # local variable declaration + local email="$1" + local invitation_id + + # Fetch all pending invitations and find the one matching the email + invitation_id=$(gh api \ + --method GET \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + /orgs/"${GH_ORG_NAME}"/invitations \ + --jq ".[] | select(.email==\"${email}\") | .id") + + if [[ $? -eq 0 ]]; then + echo "${invitation_id}" + else + echo -n "${ERR} An error occurred while obtaining the id of invitation to " + echo "'${GH_ORG_NAME}' organization." + exit 1 + fi +} + +cancel_invitations_to_organization() { + # Check if the file with emails exists and is readable + if [[ ! -r 'emails.txt' ]]; then + echo "${ERR} The file 'emails.txt' is not readable or does not exist." >&2 + exit 1 + fi + + # Local variable declaration + local email + local invitation_id + + # Reading from a file + while IFS= read -r email; do + # Fetch all pending invitations and find the one matching our email + invitation_id=$(get_invitation_id_by_email "${email}") + if [[ $? -eq 0 ]]; then + if [[ -z "${invitation_id}" ]]; then + echo "${WRN} No pending invitation found for: ${email}." + continue + fi + else + echo "${invitation_id}" >&2 + exit 1 + fi + + # Cancel the invitation + gh api \ + --method DELETE \ + /orgs/"${GH_ORG_NAME}"/invitations/"${invitation_id}" \ + --silent + + if [[ $? -eq 0 ]]; then + echo "${YUP} Cancelled the invitation for: '${email}'." + else + echo "${ERR} Failed to cancel the invitation for: '${email}'." >&2 + fi + done < 'emails.txt' +} + +# Retrieves the project ID for a specified project title within a GitHub +# organization. +# Usage: get_project_number +get_project_number() { + # Local variable declaration for the return value + local project_number + + # List projects under a specific organization and extract the project ID using + # jq based on the title. + project_number=$(gh project list \ + --owner "${GH_ORG_NAME}" \ + --format 'json' \ + --jq ".projects[] | select(.title == \"${TARGET_PROJECT_TITLE}\") | .number") + + if [[ $? -eq 0 ]]; then + echo "${project_number}" + else + echo -n "${ERR} An error occurred while obtaining the number of the " + echo -n "'${TARGET_PROJECT_TITLE}' project from the '${GH_ORG_NAME}' " + echo "organization." + exit 1 + fi +} + +# Fetches and saves the project's data as a JSON file, named with the project +# title and current timestamp. +# Usage: get_project_data_as_json +get_project_data_as_json() { + # Local variable declaration + local project_number + + timestamp=$(date "+%F-%H-%M-%S") + filename="logs/${TARGET_PROJECT_TITLE}-${timestamp}.json" + + # Check if the file can be created + if [[ -f "${filename}" ]]; then + echo "${ERR} File '${filename}' already exists." >&2 + exit 1 + elif ! touch "${filename}" &>/dev/null; then + echo -n "${ERR} File '${filename}' cannot be created. " >&2 + echo "Check permissions or disk space." >&2 + exit 1 + fi + + project_number=$(get_project_number) + if [[ $? -eq 0 ]]; then + if [[ -n "${project_number}" ]]; then + echo -n "${YUP} Obtaining the number of the '${TARGET_PROJECT_TITLE}' " + echo "project has been completed" + else + echo -n "${WRN} The '${TARGET_PROJECT_TITLE}' project within the " + echo -n "'${GH_ORG_NAME}' organization might have been closed or " + echo -n "does not exist, as the obtained number for this project " + echo -n "is empty: the '${GH_ORG_NAME}' organization has no such " + echo -n "open project. Check the 'user_config' file to ensure " + echo -n "that you have correctly entered 'GH_ORG_NAME' and " + echo "'TARGET_PROJECT_TITLE'." + exit 1 + fi + else + echo "${project_number}" >&2 + exit 1 + fi + + # Retrieves the project items list and saves it as a JSON file. + gh project item-list "${project_number}" \ + --owner "${GH_ORG_NAME}" \ + --limit "${GH_PROJECT_MAX_ITEM}" \ + --format json | jq '.' > "${filename}" + + if [[ $? -eq 0 ]]; then + echo -n "${OK} The project '${TARGET_PROJECT_TITLE}' has been logged " + echo "to the file: ${filename}" + else + echo -n "${ERR} An error occurred while logging the project " >&2 + echo "'${TARGET_PROJECT_TITLE}'" >&2 + exit 1 + fi +} +# Closes a specified project within a GitHub organization. +# Usage: close_project +close_project() { + # Local variable declaration + local project_number + + project_number=$(get_project_number) + if [[ $? -eq 0 ]]; then + if [[ -n "${project_number}" ]]; then + echo -n "${YUP} Obtaining the number of the '${TARGET_PROJECT_TITLE}' " + echo "project has been completed" + else + echo -n "${WRN} The '${TARGET_PROJECT_TITLE}' project within the " + echo -n "'${GH_ORG_NAME}' organization might have been closed or " + echo -n "does not exist, as the obtained number for this project " + echo -n "is empty: the '${GH_ORG_NAME}' organization has no such " + echo -n "open project. Check the 'user_config' file to ensure " + echo -n "that you have correctly entered 'GH_ORG_NAME' and " + echo "'TARGET_PROJECT_TITLE'." + exit 1 + fi + else + echo "${project_number}" >&2 + exit 1 + fi + + # Close the specified project. + gh project close "${project_number}" \ + --owner "${GH_ORG_NAME}" + + if [[ $? -eq 0 ]]; then + echo "${OK} The project '${TARGET_PROJECT_TITLE}' has been closed" + else + echo -n "${ERR} An error occurred while closing the project " >&2 + echo "'${TARGET_PROJECT_TITLE}'" >&2 + exit 1 + fi +} + +# Delete project TARGET_PROJECT_TITLE defined in user_config.sh file from +# GH_ORG_NAME. Usage: delete_project +delete_project() { + # Local variable declaration + local project_number + + project_number=$(get_project_number) + if [[ $? -eq 0 ]]; then + if [[ -n "${project_number}" ]]; then + echo -n "${YUP} Obtaining the number of the '${TARGET_PROJECT_TITLE}' " + echo "project has been completed" + else + echo -n "${WRN} The '${TARGET_PROJECT_TITLE}' project within the " + echo -n "'${GH_ORG_NAME}' organization might have been closed or " + echo -n "does not exist, as the obtained number for this project " + echo -n "is empty: the '${GH_ORG_NAME}' organization has no such " + echo -n "open project. Check the 'user_config' file to ensure " + echo -n "that you have correctly entered 'GH_ORG_NAME' and " + echo "'TARGET_PROJECT_TITLE'." + exit 1 + fi + else + echo "${project_number}" >&2 + exit 1 + fi + + # deleting a project + gh project delete "${project_number}" \ + --owner "${GH_ORG_NAME}" + + if [[ $? -ne 0 ]]; then + echo -n "${ERR} An error occurred while deleting the " >&2 + echo "'${TARGET_PROJECT_TITLE}' from '${GH_ORG_NAME}'" >&2 + exit 1 + fi + echo -n "${OK} Deletion of the '${TARGET_PROJECT_TITLE}' project " + echo "has been completed." +} + + +# Creates a set of private repositories from templates without cloning them. +# Usage: create_repo_from_template +create_repo_from_template() { + # Iterate through the source repositories and create private repositories from + # templates + for ((i=0; i<"${#SOURCE_REPOS[@]}"; i++)); do + + # Create private repo from the template without cloning it. + gh repo create "${GH_ORG_NAME}"/"${TARGET_REPOS[i]}" \ + --private \ + --template "${GH_ORG_NAME}"/"${SOURCE_REPOS[i]}" \ + --clone=false + + if [[ $? -ne 0 ]]; then + echo -n "${ERR} An error occurred while creating the " >&2 + echo "${GH_ORG_NAME}/${TARGET_REPOS[i]} repo from the " >&2 + echo "${GH_ORG_NAME}/${SOURCE_REPOS[i]} template" >&2 + exit 1 + fi + done + echo "${OK} Creating repos from templates has been completed" +} + +# Assigns a repository to a specific team within the GitHub organization. +# Usage: assign_repo_to_team +assign_repo_to_team() { + # Assigning a first argument to a local variable + local repo="$1" + + # Add a repository to the team + gh api \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + --method PUT \ + /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}"/repos/"${GH_ORG_NAME}"/"${repo}" \ + --raw-field permission="${GH_TEAM_REPO_PERMISSION}" \ + --silent + + # Check if the command succeeded + if [[ $? -eq 0 ]]; then + echo -n "Repository '${repo}' was successfully added to " + echo "the '${GH_TEAM_NAME}' team." + else + echo -n "${ERR} An error occurred while adding the repository '${repo}' " >&2 + echo "to the '${GH_TEAM_NAME}' team in the '${GH_ORG_NAME}' organization." >&2 + exit 1 + fi +} + +# Removes a repository from a specific team within the GitHub organization. +# Usage: remove_repo_from_team +remove_repo_from_team() { + # Assigning the first argument to a local variable + local repo="$1" + + # Remove the repository from the team + gh api \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + --method DELETE \ + /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}"/repos/"${GH_ORG_NAME}"/"${repo}" \ + --silent + + # Check if the command succeeded + if [[ $? -eq 0 ]]; then + echo -n "${YUP} Repository '${repo}' was successfully removed from " + echo "the '${GH_TEAM_NAME}' team." + else + echo -n "${ERR} An error occurred while removing the repository '${repo}' " >&2 + echo "from the '${GH_TEAM_NAME}' team in the '${GH_ORG_NAME}' organization." >&2 + exit 1 + fi +} + +# Get the team's repositories list. Usage: get_team_repos_list +get_team_repos_list() { + + # Declare a local variable for the api response to avoid printing potential + # errors to the pager; instead, we print them directly to the console. + local response + + # API request for a team's repos list + response=$(gh api \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + --method GET \ + /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}"/repos \ + --paginate \ + --jq '.[].name') + + # Check if the command succeeded + if [[ $? -eq 0 ]]; then + echo "${response}" + else + echo -n "${ERR} An error occurred while getting the repository list of " >&2 + echo "the '${GH_TEAM_NAME}' team in the '${GH_ORG_NAME}' organization." >&2 + exit 1 + fi +} + +get_team_members_list() { + + # Expect exactly one argument: name of output array + if [[ "$#" -ne 1 || -z "$1" ]]; then + echo "${ERR} Usage: ${FUNCNAME[0]} " >&2 + exit 1 + fi + + # Name reference to caller's array + local -n _team_members="$1" + local status + + mapfile -t _team_members < <( + gh api \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + --method GET \ + /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}"/members \ + --paginate \ + --jq '.[].login' + ) + status=$? + + if [[ $status -ne 0 ]]; then + echo -n "${ERR} An error occurred while obtaining the members list of " >&2 + echo "the '${GH_TEAM_NAME}' team from the '${GH_ORG_NAME}' organization." >&2 + exit 1 + fi +} + +get_team_member_role() { + + # Check if the correct number of non-empty arguments is passed + if [[ "$#" -ne 1 || -z "$1" ]]; then + echo "${ERR} Invalid number of arguments or empty argument." >&2 + echo "${WRN} Usage: ${FUNCNAME[0]} " + exit 1 + fi + + local member="$1" + # Declare a local variable for the return value + local member_role + + member_role=$(gh api \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + --method GET \ + /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}"/memberships/"${member}" \ + --jq '.role') + + if [[ $? -eq 0 ]]; then + echo "${member_role}" + else + echo -n "${ERR} An error occurred while obtaining the '${GH_TEAM_NAME}'" + echo " team member role in the '${GH_ORG_NAME}' organization. " + exit 1 + fi +} + +remove_team_members_from_org() { + + local -a team_members + local member_role + + # Fill array by reference + get_team_members_list team_members + + echo -n "${YUP} Obtaining the members list of the '${GH_TEAM_NAME}' " + echo "has been completed" + + for member in "${team_members[@]}"; do + member_role=$(get_team_member_role "${member}") + if [[ $? -eq 0 ]]; then + echo -n "${YUP} Obtaining the team role of '${member}' in the " + echo "'${GH_TEAM_NAME}' team has been completed" + else + echo "${member_role}" >&2 + exit 1 + fi + + echo "member: ${member}, role: ${member_role}" + # Remove user from the organization if they are a 'member' of the team + if [[ "${member_role}" == 'member' ]]; then + gh api \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + --method DELETE \ + /orgs/"${GH_ORG_NAME}"/members/"${member}" \ + --silent + + if [[ $? -eq 0 ]]; then + echo -n "'${member}' successfully removed from the '${GH_ORG_NAME}' " + echo "organization." + else + echo -n "Failed to remove '${member}' from the '${GH_ORG_NAME}' " + echo "organization." + exit 1 + fi + else + echo "'${member}' is not a 'member' role user in the team, skipping..." + fi + done +} + +# Deletes a specified team from the GitHub organization. +# Usage: delete_team +delete_team() { + # API request for a team deletion + gh api \ + --method DELETE \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + /orgs/"${GH_ORG_NAME}"/teams/"${GH_TEAM_NAME}" \ + --silent + + # Check whether the operation was successful + if [[ $? -eq 0 ]]; then + echo -n "${OK} The '${GH_TEAM_NAME}' team has been deleted " + echo "from the '${GH_ORG_NAME}' organization." + else + echo -n "${ERR} An error occurred while deleting the '${GH_TEAM_NAME}'" >&2 + echo " team from the '${GH_ORG_NAME}' organization." >&2 + exit 1 + fi +} + +# Deletes the entire GitHub organization. +# Usage: delete_organization +delete_organization() { + # API request for an organization deletion + gh api \ + --method DELETE \ + --header "Accept: ${GH_API_ACCEPT_HEADER}" \ + --header "X-GitHub-Api-Version: ${GH_API_VERSION_HEADER}" \ + /orgs/"${GH_ORG_NAME}" \ + --silent + + # Check whether the operation was successful + if [[ $? -eq 0 ]]; then + echo "${OK} The '${GH_ORG_NAME}' organization has been deleted " + else + echo -n "${ERR} An error occurred while deleting the " >&2 + echo "'${GH_ORG_NAME}' organization" >&2 + exit 1 + fi +} + +# THE MAIN COMMANDS-------------------------------------------------------------# +# The 'delete' command deletes the entire GitHub organization. Depends on the +# 'user_config.sh' and 'system_config.sh' files. For organization deletion we +# need the 'admin:org' scope. +delete() { + + # Asking the user for confirmation for deleting the organization + echo -n "${WRN} Are you sure you want to delete the organization " + echo "'${GH_ORG_NAME}'? (no/yes) [no]:" + read user_confirm + + if [[ "${user_confirm}" == 'yes' ]]; then + # Requesting the user to type the organization name for final confirmation + echo "${WRN} Please type the name of the organization to confirm deletion: " + read typed_org_name + + if [[ "${typed_org_name}" == "${GH_ORG_NAME}" ]]; then + echo 'Running delete_organization()' + delete_organization + else + echo -n "${WRN} The typed name '${typed_org_name}' does not match " + echo "the organization name '${GH_ORG_NAME}'. Aborting deletion." + fi + else + echo "${WRN} Deletion aborted by the user." + fi +} + +# The 'close' command concludes a course by archiving project data and +# optionally deleting the team associated with the course. Depends on the +# 'user_config.sh' and 'system_config.sh' files. +close() { + + # Archive data before closing the project + echo 'Running get_project_data_as_json()' + get_project_data_as_json + + echo 'Running close_project()' + close_project + + # Asking the user for confirmation for deleting the team + echo -n "${WRN} Are you sure you want to delete the team '${GH_TEAM_NAME}' " + echo "from organization '${GH_ORG_NAME}'? (no/yes) [no]:" + read user_confirm + + # Asking the user for confirmation for removing members from an organization + if [[ "${user_confirm}" == 'yes' ]]; then + echo -n "${WRN} Do you want the team members, excluding the team " + echo -n "maintainers, to be removed from the organization '${GH_ORG_NAME}'?" + echo " (no/yes) [no]:" + read user_confirm + + if [[ "${user_confirm}" == 'yes' ]]; then + echo 'Running remove_team_members_from_org()' + remove_team_members_from_org + fi + + echo 'Running delete_team()' + delete_team + fi +} + +# The 'status' command list the repositories added to the team +# 'GH_TEAM_NAME'. Depends on the 'user_config.sh' and 'system_config.sh' files. +status() { + + echo 'Running get_team_repos_list()' + get_team_repos_list +} + +# The 'log' commnad captures and logs the current state of a specified GitHub +# project 'TARGET_PROJECT_TITLE' into a JSON file for archival or potential +# analysis purposes. Depends on the 'user_config.sh' file. +log() { + + echo 'Running get_project_data_as_json()' + get_project_data_as_json +} + +# The 'unassign' command removes a specified repository from a team +# 'GH_TEAM_NAME' within the GitHub organization 'GH_ORG_NAME'. Depends on the +# 'user_config.sh' and 'system_config.sh' files. Usage: unassign +unassign() { + + # Assigning a first argument – repository name – to a local variable. + local repo="$1" + + echo 'Running remove_repo_from_team()' + remove_repo_from_team "${repo}" +} + +# The 'assign' command assigns a specified repository to a team 'GH_TEAM_NAME' +# within the GitHub organization 'GH_ORG_NAME'. Depends on the 'user_config.sh' +# and 'system_config.sh' files. Usage: assign +assign() { + + # Assigning a first argument – repository name – to a local variable. + local repo="$1" + + echo 'Running add_repo_to_team()' + assign_repo_to_team "${repo}" +} + +disinvite() { + # This API operation needs the "admin:org" scope which is not set by + # default. To request it, run: gh auth refresh -h github.com -s + # admin:org + echo 'Running cancel_invitations_to_organization()' + cancel_invitations_to_organization +} + +invite() { + # Disable the script's trap on ERR (because we don't want to print info about + # error but just warning if the HTTP status code is 422). + set +o errtrace + + # This API operation needs the "admin:org" scope which is not set by default. To + # request it, run: gh auth refresh -h github.com -s admin:org + echo 'Running invite_to_organization_team()' + invite_to_organization_team +} + +# Opening a course involves, in addition to making some necessary organizational +# settings, creating a 'GH_TEAM_NAME' team to which students will be added after +# they accept the invitations to join the organization sent during the course +# opening operation. Team settings are adjusted here to ensure the mechanism for +# the automatic selection of a reviewer functions properly. Individuals excluded +# from reviewing, such as teachers, are listed in +# 'GH_TEAM_MEMBERS_EXCLUDED_FROM_REVIEWING'. Subsequently, a GitHub project +# titled 'TARGET_PROJECT_TITLE' is linked to the 'GH_TEAM_NAME' team. At this +# stage, task repositories are cloned to a local folder. Finally, repositories +# containing tasks are configured to protect them against modifications by +# students. Students are required to fork the repository to their account in +# order to make their changes. Depends on the 'user_config.sh' and +# 'system_config.sh' files. +open() { + + # Aborts the script when the first error is detected. + set -o errexit + + if [[ ! -f 'emails.txt' ]]; then + echo -n "${WRN} The 'emails.txt' file is missing. Please run " + echo "the 'soc init' command to create the missing file." + exit 1 + fi + + # Check if username and email for git have been configured. + check_git_configs + + # Target repositories validation + # Check if the newly created target repositories on GH_ORG_NAME by `sync + # command` exist so that we can clone them. + echo 'Running check_if_repos_exist()' + check_if_repos_exist "${GH_ORG_NAME}" \ + "TARGET_REPOS" \ + "${TARGET_REPOS[@]}" + + # Disable the script's abort-on-first-error feature. + set +o errexit + + echo 'Running update_org_default_repo_permission()' + update_org_default_repo_permission + echo 'Running update_org_members_can_fork_private_repo()' + update_org_members_can_fork_private_repo + + echo 'Running create_team()' + create_team + + # By default, the command below sets the team's project permissions to read. + # IDEA: it may be worth implementing function for changing the Organization + # settings: Settings –> Member privileges –> Projects base permissions [Read] + # "Projects created by members will default to the elected role." + # get_team_id – gh api graphql + # link_project_to_team – gh api graphql + echo 'Running link_project_to_team()' + link_project_to_team + + # UpdateTeamReviewAssignmentInput is available under the Team review + # assignments preview. During the preview period, the API may change without + # notice. + # https://docs.github.com/en/graphql/reference/mutations#updateteamreviewassignment + # https://docs.github.com/en/graphql/reference/input-objects#updateteamreviewassignmentinput + + # TeamReviewAssignmentAlgorithm is available under the Team review + # assignments preview. During the preview period, the API may change without + # notice. \"$TeamReviewAssignmentAlgorithm\", + # get_team_id – gh api graphql + # enable_team_review_assignment – gh api graphql + echo 'Running enable_team_review_assignment()' + enable_team_review_assignment + + # It is required to run sync to create target repositories on the GH_ORG_NAME + # account - all functions below depend on it + echo 'Running clone_repositories()' + clone_repositories + + # !!!!!!!These functions require local repositories!!!!!!!!!!!!!!!!!!!!! + + # # [GitHub docs]: You can define code owners in public repositories with GitHub + # # Free and GitHub Free for organizations, and in public and private + # # repositories with GitHub Pro, GitHub Team, GitHub Enterprise Cloud, and + # # GitHub Enterprise Server. + echo 'Running set_team_as_codeowners()' + set_team_as_codeowners + + # Aborts the script when the first error is detected. + set -o errexit + + echo 'Running commit_changes_and_push_to_remote_repos()' + commit_changes_and_push_to_remote_repos + # !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + + # Disable the script's abort-on-first-error feature. + set +o errexit + + # # [GitHub docs]: Protected branches are available in public repositories with + # # GitHub Free and GitHub Free for organizations, and in public and private + # # repositories with GitHub Pro, GitHub Team, GitHub Enterprise Cloud, and + # # GitHub Enterprise Server. + # # [GitHub docs]: Branch is read-only. Users cannot push to the branch. + echo 'Running lock_branch()' + lock_branch + + # [GitHub docs]: When enabled, all conversations on code must be resolved before + # a pull request can be merged into a branch that matches this rule + echo 'Running require_conversation_resolution_before_merging()' + require_conversation_resolution_before_merging + + # GitHub Pages is available in private repositories with GitHub Pro, GitHub + # Team, GitHub Enterprise Cloud, and GitHub Enterprise Server. + echo 'Running swich_on_gh_pages()' + swich_on_gh_pages + echo 'Running set_website_link()' + set_repos_website +} + +unsync() { + # Aborts the script when the first error is detected. + set -o errexit + + local unsync_from="$1" + + # Disable the script's abort-on-first-error feature. + set +o errexit + + if [[ "${unsync_from}" == 'project' || "${unsync_from}" == '' ]]; then + # Asking the user for confirmation for removing the project + echo -n "${WRN} Are you sure you want to remove the ${TARGET_PROJECT_TITLE}" + echo " project from the organization '${GH_ORG_NAME}'? (no/yes) [no]:" + read user_confirm + + if [[ "${user_confirm}" == 'yes' ]]; then + echo 'Running delete_project()' + delete_project + fi + fi + + if [[ "${unsync_from}" == 'repos' || "${unsync_from}" == '' ]]; then + # Aborts the script when the first error is detected. + set -o errexit + # Target repositories validation + # Check if the target repositories exist so that we can delete them. + echo 'Running check_if_repos_exist()' + check_if_repos_exist "${GH_ORG_NAME}" \ + "TARGET_REPOS" \ + "${TARGET_REPOS[@]}" + + # Disable the script's abort-on-first-error feature. + set +o errexit + + # Asking the user for confirmation for removing all repos + echo -n "${WRN} Are you sure you want to remove all " + echo "repositories listed in 'TARGET_REPOS' array: " + printf "%s\n" "${TARGET_REPOS[@]}" + echo "from the organization '${GH_ORG_NAME}'? (no/yes) [no]:" + read user_confirm + + if [[ "${user_confirm}" == 'yes' ]]; then + echo 'Running delete_repositories()' + delete_repositories + fi + fi +} + +# This command creates a GitHub project titled 'TARGET_PROJECT_TITLE' from the +# template number 'SOURCE_PROJECT_NUMBER' and 'SOURCE_PROJECT_OWNER' GitHub +# account, as specified in source_config. This project is utilized to gather +# information on work progress. Subsequently, repositories with names defined in +# 'TARGET_REPOS' array in the user_config.sh file are created based on templates +# repostories defined in 'SOURCE_REPOS' array in the source_config.sh +# file. Depends on the 'user_config.sh', 'source_config.sh' and +# 'system_config.sh' files. +sync() { + + # Aborts the script when the first error is detected. + set -o errexit + + local sync_with="$1" + + # Disable the script's abort-on-first-error feature. + set +o errexit + + if [[ "${sync_with}" == 'project' || "${sync_with}" == '' ]]; then + # In an organization with the Team plan, we have more opportunities to create + # charts in Insights than with a user account on the 'Pro' plan. For example, + # we can create a graph where x represents time. + echo 'Running create_project_from_template()' + create_project_from_template + fi + + if [[ "${sync_with}" == 'repos' || "${sync_with}" == '' ]]; then + # Aborts the script when the first error is detected. + set -o errexit + + # Source repositories validation + # Check if the source repositories exist so that we can use them. We can't + # start repos creating if one of these doesn't exist. + echo 'Running check_if_repos_exist()' + check_if_repos_exist "${SOURCE_REPOS_OWNER}" \ + "SOURCE_REPOS" \ + "${SOURCE_REPOS[@]}" + + # Checks if source repositories are templates, so that we can create + # repositories based on them. Attempt to make them templates if they are not + # already. + ensure_repos_as_templates "${SOURCE_REPOS_OWNER}" \ + "SOURCE_REPOS" \ + "${SOURCE_REPOS[@]}" + + # Target repositories validation + # Check if the target repositories not yet exist so that we can create them + echo 'Running check_if_repos_noexist()' + check_if_repos_noexist "${GH_ORG_NAME}" \ + "TARGET_REPOS" \ + "${TARGET_REPOS[@]}" + + + # Disable the script's abort-on-first-error feature. + set +o errexit + + # Create repositories defined in the 'TARGET_REPOS' array in the + # 'user_config.sh' from the 'SOURCE_REPOS' repository templates array in the + # 'source_config.sh' file. + echo 'Running create_private_repos_from_templates()' + create_private_repos_from_templates + echo 'Running set_repos_descriptions()' + set_repos_descriptions + echo 'Running update_repo_without_cloning()' + update_repo_without_cloning + fi +} + +# Performs global variable validations from configuration files. Depends on +# the 'user_config.sh' and 'source_config.sh' files. +precheck() { + # Variable that tracks when an error occurred + local error_occurred=0 + # The first argument of precheck is the verbosity level 0 or 1; 0 is default + local VERBOSE=${1:-0} + + if [[ "${VERBOSE}" -eq 1 ]]; then + echo "Commands validation:" + fi + if ! check_if_gh_installed; then error_occurred=1; fi + if ! check_if_git_installed; then error_occurred=1; fi + if ! check_if_sed_installed; then error_occurred=1; fi + if ! check_if_grep_installed; then error_occurred=1; fi + if ! check_if_jq_installed; then error_occurred=1; fi + if ! check_if_date_installed; then error_occurred=1; fi + if ! check_if_touch_installed; then error_occurred=1; fi + + if [[ "${VERBOSE}" -eq 1 ]]; then + echo "Validation of global variables from the 'user_config.sh' file:" + fi + # Variables validations + if ! validate_owner_name "GH_ORG_NAME" "${GH_ORG_NAME}"; then + error_occurred=1; + fi + + if ! validate_team_name "GH_TEAM_NAME" "${GH_TEAM_NAME}"; then + error_occurred=1; + fi + if ! validate_user_names "GH_TEAM_MEMBERS_EXCLUDED_FROM_REVIEWING" \ + "${GH_TEAM_MEMBERS_EXCLUDED_FROM_REVIEWING[@]}"; then + error_occurred=1; + fi + + if ! validate_non_empty_string "TARGET_PROJECT_TITLE" "${TARGET_PROJECT_TITLE}"; then + error_occurred=1; + fi + + # Arrays validations + if ! validate_non_empty_array "TARGET_REPOS" "${TARGET_REPOS[@]}"; then + error_occurred=1; + fi + if ! validate_repo_names "TARGET_REPOS" "${TARGET_REPOS[@]}"; then + error_occurred=1; + fi + if ! validate_no_duplicates "TARGET_REPOS" "${TARGET_REPOS[@]}"; then + error_occurred=1; + fi + + if [[ "${VERBOSE}" -eq 1 ]]; then + echo "Validation of global variables from the 'source_config.sh' file:" + fi + # Variable validations + if ! validate_owner_name "SOURCE_REPOS_OWNER" "${SOURCE_REPOS_OWNER}"; then + error_occurred=1; + fi + if ! validate_owner_name "SOURCE_PROJECT_OWNER" "${SOURCE_PROJECT_OWNER}"; then + error_occurred=1; + fi + if ! validate_numeric "SOURCE_PROJECT_NUMBER" "${SOURCE_PROJECT_NUMBER}"; then + error_occurred=1; + fi + + # Arrays validations + if ! validate_non_empty_array "SOURCE_REPOS" "${SOURCE_REPOS[@]}"; then + error_occurred=1; + fi + if ! validate_repo_names "SOURCE_REPOS" "${SOURCE_REPOS[@]}"; then + error_occurred=1; + fi + if ! validate_no_duplicates "SOURCE_REPOS" "${SOURCE_REPOS[@]}"; then + error_occurred=1; + fi + if ! validate_non_empty_array "SOURCE_MAIN_REPO_INFO_FILES" "${SOURCE_MAIN_REPO_INFO_FILES[@]}"; then + error_occurred=1; + fi + if ! validate_no_empty_array_elements "SOURCE_MAIN_REPO_INFO_FILES" "${SOURCE_MAIN_REPO_INFO_FILES[@]}"; then + error_occurred=1; + fi + if ! validate_no_duplicates "SOURCE_MAIN_REPO_INFO_FILES" "${SOURCE_MAIN_REPO_INFO_FILES[@]}"; then + error_occurred=1; + fi + + # Check if any error occurred + if [[ "${error_occurred}" -eq 1 ]]; then + echo "{$ERR} Errors occurred during precheck. " >&2 + exit 1 + fi +} + +# The init command creates configuration files user_config.sh, source_config.sh, +# system_config.sh, and an additional file for student emails named +# emails.txt. It also creates a folder named 'logs' to store JSON data files. +init() { +# Aborts the script when the first error is detected. +set -o errexit + +# Create directory if it doesn't already exist +mkdir -p logs + +# Define the path for configuration files +local user_config_path="./user_config.sh" +local source_config_path="./source_config.sh" +local system_config_path="./system_config.sh" + +# Define the path for emails file +local emails_path="./emails.txt" + +# Check if the emails.txt file already exists, if not, create it with +# example values +if [[ ! -f "${emails_path}" ]]; then +cat << 'EOF' > "${emails_path}" +first@example.com +second@example.com +third@example.com +EOF +echo "${YUP} The file ${emails_path} has been created." +else + echo "${WRN} The file ${emails_path} already exists." +fi + +# Check if the user_config.sh file already exists, if not, create it with +# default values +if [[ ! -f "${user_config_path}" ]]; then +cat << 'EOF' > "${user_config_path}" +# ORGANIZATION CONFIGS----------------------------------------------------------# +# The organization name may only contain alphanumeric characters or single +# hyphens, and cannot begin or end with a hyphen ('-'). +readonly GH_ORG_NAME='' # insert here your GitHub organization name + +# TEAM CONFIGS------------------------------------------------------------------# +# The team name name may contain only alphanumeric characters, a hyphen-minus +# character ('-'), and an underscore ('_'). +readonly GH_TEAM_NAME='SoC-DS' + +# The team description may contain any character. +readonly GH_TEAM_DESCRIPTION='SoC Data Science Course' + +# The github logins may only contain alphanumeric characters or single +# hyphens, and cannot begin or end with a hyphen ('-'). +readonly GH_TEAM_MEMBERS_EXCLUDED_FROM_REVIEWING=() + +# REPOSITORY CONFIGS------------------------------------------------------------# +# The repository description may contain any character. +readonly TARGET_REPO_DESCRIPTION='SoC Data Science Course' + +# New repository names: rename according to your preferences, but choose names +# that are not part of others. The first repository name may be an exception, as +# in our example: the repository name 'soc-datascience' is part of others. Names +# may contain only alphanumeric characters, a hyphen-minus +# character ('-'), an underscore ('_') and a dot ('.'). +readonly TARGET_REPOS=( + 'soc-datascience' + 'soc-datascience-hello' + 'soc-datascience-viz' + 'soc-datascience-wrang' + 'soc-datascience-spatial' + 'soc-datascience-reshape' + 'soc-datascience-paradox' + 'soc-datascience-collabor' + 'soc-datascience-scrap' + 'soc-datascience-bias' + 'soc-datascience-fitting' + 'soc-datascience-nfitting' + 'soc-datascience-valid' + 'soc-datascience-hypo' + 'soc-datascience-wrapup' + 'soc-datascience-project' +) + +# PROJECT CONFIGS---------------------------------------------------------------# +# The project title may contain any character. +readonly TARGET_PROJECT_TITLE='monitor-SoC-DS' +EOF +echo "${YUP} The file ${user_config_path} has been created." +else + echo "⚡ The file ${user_config_path} already exists." +fi + +# Check if the source_config.sh file already exists, if not, create it with +# default values +if [[ ! -f "${source_config_path}" ]]; then +cat << 'EOF' > "${source_config_path}" +# REPOSITORY CONFIGS------------------------------------------------------------# +readonly SOURCE_REPOS_OWNER='the-soc-org' +readonly SOURCE_REPOS=( + 'soc-datascience' + 'soc-datascience-hello' + 'soc-datascience-viz' + 'soc-datascience-wrang' + 'soc-datascience-spatial' + 'soc-datascience-reshape' + 'soc-datascience-paradox' + 'soc-datascience-collabor' + 'soc-datascience-scrap' + 'soc-datascience-bias' + 'soc-datascience-fitting' + 'soc-datascience-nfitting' + 'soc-datascience-valid' + 'soc-datascience-hypo' + 'soc-datascience-wrapup' + 'soc-datascience-project' +) + +# The main repo is the first repository listed in the SOURCE_REPOS array. At +# least one info file must exist in the main repository. If there is no info +# file, an error message will be displayed. +readonly SOURCE_MAIN_REPO_INFO_FILES=( + 'README.md' + 'index.md' +) + +# PROJECT CONFIGS---------------------------------------------------------------# +readonly SOURCE_PROJECT_OWNER='the-soc-org' +readonly SOURCE_PROJECT_NUMBER='1' +EOF +echo "${YUP} The file ${source_config_path} has been created." +else + echo "${WRN} The file ${source_config_path} already exists." +fi + +# Check if the system_config.sh file already exists, if not, create it with +# default values +if [[ ! -f "${system_config_path}" ]]; then +cat << 'EOF' > "${system_config_path}" +# [GitHub API docs]: https://docs.github.com/en/rest?apiVersion=2022-11-28 +# [GitHub GraphQL docs]: https://docs.github.com/en/graphql/reference + +# SOC CLI CONFIGS---------------------------------------------------------------# +# The platform CLI used by soc commands. The 'soc' dispatcher reads this value +# to select the correct set of platform-specific command scripts (e.g., +# 'soc-gh-open' for GitHub CLI). Supported values: 'gh' (GitHub CLI). +readonly SOC_PLATFORM_CLI='gh' + +# API HEADERS CONFIGS-----------------------------------------------------------# +readonly GH_API_ACCEPT_HEADER='application/vnd.github+json' +readonly GH_API_VERSION_HEADER='2022-11-28' + +# GITHUB CLI CONFIGS------------------------------------------------------------# +readonly GH_CLI_TOKEN_SCOPES=('admin:org' 'delete_repo' 'project' 'repo') + +# ORGANIZATION CONFIGS----------------------------------------------------------# +# [GitHub API docs] The role for the new member. + +# admin – Organization owners with full administrative rights to the +# organization and complete access to all repositories and teams. + +# direct_member – Non-owner organization members with ability to see other +# members and join teams by invitation. + +# billing_manager – Non-owner organization members with ability to manage the +# billing settings of your organization. + +# reinstate – The previous role assigned to the invitee before they were removed +# from your organization. Can be one of the roles listed above. Only works if +# the invitee was previously part of your organization. + +readonly GH_ORG_ROLE='direct_member' + +# [GitHub API docs] Default permission level members have for organization +# repositories. Can be one of: read, write, admin, none. GitHub default: read +readonly GH_ORG_DEFAULT_REPOSITORY_PERMISSION='none' + +# [GitHub API docs] Whether organization members can fork private organization +# repositories. GitHub Default: false; the value other than 'false' will be +# treated as 'true' +readonly GH_ORG_MEMBERS_CAN_FORK_PRIVATE_REPOSITORIES=true + +# TEAM CONFIGS------------------------------------------------------------------# +# [GitHub API docs] Team privacy settings. +# secret – only visible to organization owners and members of this team. +# closed – visible to all members of this organization. +# GitHub default: secret +readonly GH_TEAM_PRIVACY='closed' + +# [GitHub API docs] The permission to grant the team on this repository. We +# accept the following permissions to be set: pull, triage, push, maintain, admin +# and you can also specify a custom repository role name, if the owning +# organization has defined any. If no permission is specified, the team's +# permission attribute will be used to determine what permission to grant the team +# on this repository. +# GitHub default: push +readonly GH_TEAM_REPO_PERMISSION='push' + +# [GitHub GraphQL API docs] Notifications settings. +# notifications_enabled – Team members get notified when the team is @mentioned. +# notifications_disabled – no one receives notifications. +# GitHub default: notifications_enabled +readonly GH_TEAM_NOTIFICATIONS='notifications_enabled' + +# [GitHub GraphQL API docs] The algorithm to use for review assignment +# LOAD_BALANCE – Balance review load across the entire team. +# ROUND_ROBIN – Alternate reviews between each team member. +readonly GH_TEAM_REVIEW_ASSIGNMENT_ALGORITHM='LOAD_BALANCE' + +# [GitHub GraphQL API docs] Notify the entire team of the PR if it is delegated +readonly GH_TEAM_NOTIFY=false + +# [GitHub GraphQL API docs] The number of team members randomly selected to +# review the PR +readonly GH_TEAM_MEMBER_COUNT=1 + +# REPOSITORY CONFIGS------------------------------------------------------------# +# [GitHub API docs] The name of the branch. Cannot contain wildcard characters. +readonly GH_REPO_BRANCH='main' + +# PROJECT CONFIGS---------------------------------------------------------------# +# Maximum number of items to fetch from project; log commnad uses it +readonly GH_PROJECT_MAX_ITEM=1000 +EOF +echo "${YUP} The file ${system_config_path} has been created." +else + echo "${WRN} The file ${system_config_path} already exists." +fi + +echo "${YUP} Initialization complete." +} diff --git a/soc-lib.sh b/soc-lib.sh new file mode 100644 index 0000000..dbbb158 --- /dev/null +++ b/soc-lib.sh @@ -0,0 +1,372 @@ +#!/usr/bin/env bash + +# Set up strict error handling to make debugging easier and improve reliability. +# - `+o errexit`: Disable the script's abort-on-first-error feature. +# - `-o nounset`: Aborts the script if an uninitialized variable is used. +# - `-o errtrace`: Allows the ERR trap to be inherited by functions, command substitutions, and subshells. + +set +o errexit +set -o nounset +set -o errtrace + +# Establish a trap for any error that occurs, calling the `error_handler` +# function with the source file and line number as arguments. +trap 'error_handler ${BASH_SOURCE} ${LINENO}' ERR + +# Define visual indicators for the terminal output to enhance readability. +# - `OK`: White Heavy Check Mark (Unicode: U+2705), indicates success. +# - `YUP`: Check Mark (Unicode: U+2713), also indicates confirmation or success. +# - `WRN`: High Voltage Sign (Unicode: U+26A1), used to signal warnings or cautions. +# - `ERR`: Cross Mark (Unicode: U+274C), indicates errors or failure. +OK=✅ +YUP=✓ +WRN=⚡ +ERR=❌ + +# Handles errors by printing the source file and line number where the error +# occurred. Usage: error_handler +error_handler() { + local src="$1" + local line="$2" + + echo "${ERR} Error: in ${src} at line ${line}" >&2 +} + +# Removes a temporary file, if it exists, and prints a confirmation message. +# Usage: remove_tmp_file +remove_tmp_file() { + local file_to_remove="$1" + + if [[ -f "$file_to_remove" ]]; then + rm -f "${file_to_remove}" + echo "${YUP} Temporary file '${file_to_remove}' has been deleted" + fi +} + +check_git_configs() { + + local git_user_name + local git_user_email + + git_user_name=$(git config --global user.name) + git_user_email=$(git config --global user.email) + + # Check if the user has set up the git user name and email + if [[ -z "${git_user_email}" ]]; then + echo -n "${WRN} Git user email is not set. You can set it up with: " + echo "'git config --global user.email \"your_email@example.com\"'." + fi + + if [[ -z "${git_user_name}" ]]; then + echo -n "${ERR} Git user name is not set. For proper functioning of " >&2 + echo -n "'${FUNCNAME[1]}' command, it is required that the git user " >&2 + echo -n "name is set up on the local machine. Please run:" >&2 + echo "'git config --global user.name \"Your Name\"'." >&2 + exit 1 + fi +} + +# Sources user_config.sh, source_config.sh, and system_config.sh from the +# current working directory. Aborts with an error message if any file is +# missing. Usage: source_config_files +source_config_files() { + if [[ -f 'user_config.sh' && -f 'source_config.sh' && -f 'system_config.sh' ]]; then + # shellcheck source=/dev/null + source user_config.sh + # shellcheck source=/dev/null + source source_config.sh + # shellcheck source=/dev/null + source system_config.sh + else + echo "${WRN} The 'user_config.sh', 'source_config.sh' and/or 'system_config.sh' " + echo "file is missing. Please run the 'soc init' command to create the " + echo "missing file(s)." + exit 1 + fi +} + +# Check if git is installed +check_if_git_installed() { + if ! command -v git &> /dev/null; then + echo -n "${ERR} 'git' is not installed on your computer. " >&2 + echo "Install to continue: https://www.git-scm.com/downloads" >&2 + return 1 + elif [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} 'git' is installed on your computer." + fi +} + +check_if_sed_installed() { + if ! command -v sed &> /dev/null; then + echo -n "${ERR} 'sed' – a command-line utility for parsing and " >&2 + echo -n "transforming text is not installed. Install it to continue: " >&2 + echo "https://www.gnu.org/software/sed/" >&2 + return 1 + elif [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} 'sed' is installed on your computer." + fi +} + +check_if_grep_installed() { + if ! command -v grep &> /dev/null; then + echo -n "${ERR} 'grep' – a command-line utility for searching text " >&2 + echo -n "that match a regular expression is not installed. " >&2 + echo "Install it to continue:https://www.gnu.org/software/grep/ " >&2 + return 1 + elif [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} 'grep' is installed on your computer." + fi +} + +check_if_jq_installed() { + if ! command -v jq &> /dev/null; then + echo -n "${ERR} 'jq' – a lightweight and flexible command-line JSON " >&2 + echo -n "processor is not installed. Install it to continue. " >&2 + echo "https://stedolan.github.io/jq/download/" >&2 + return 1 + elif [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} 'jq' is installed on your computer." + fi +} + +check_if_date_installed() { + if ! command -v date &> /dev/null; then + echo -n "${ERR} 'date' – a command-line utility for displaying the " >&2 + echo -n "system date and time is not installed. Install it to continue. " >&2 + echo "https://www.gnu.org/software/coreutils/" >&2 + return 1 + elif [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} 'date' is installed on your computer." + fi +} + +check_if_touch_installed() { + if ! command -v touch &> /dev/null; then + echo -n "${ERR} 'touch' – a command-line utility for changing file " >&2 + echo -n "timestamps is not installed. Install it to continue. " >&2 + echo "https://www.gnu.org/software/coreutils/" >&2 + return 1 + elif [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} 'touch' is installed on your computer." + fi +} + +validate_non_empty_string() { + if [[ "$#" -lt 2 ]]; then + echo -n "${ERR} Insufficient arguments provided for the " >&2 + echo "'${FUNCNAME[0]}' function." >&2 + exit 1 + fi + + local var_name="$1" + local var_value="$2" + + if [[ -z "${var_value}" ]]; then + echo "${ERR} '${var_name}' is empty." >&2 + return 1 + elif [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} '${var_name}' is non-empty: '${var_value}'." + fi +} + +validate_owner_name() { + if [[ "$#" -lt 2 ]]; then + echo -n "${ERR} Insufficient arguments provided for the " >&2 + echo "'${FUNCNAME[0]}' function." >&2 + exit 1 + fi + + local var_name="$1" + local var_value="$2" + + if [[ "${var_value}" =~ ^[a-zA-Z0-9]+(-[a-zA-Z0-9]+)*$ ]]; then + if [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} '${var_name}' has valid value: '${var_value}'." + fi + else + echo "${ERR} '${var_name}' has invalid value: '${var_value}'." >&2 + return 1 + fi +} + +validate_team_name() { + if [[ "$#" -lt 2 ]]; then + echo -n "${ERR} Insufficient arguments provided for the " >&2 + echo "'${FUNCNAME[0]}' function." >&2 + exit 1 + fi + + local var_name="$1" + local var_value="$2" + + if [[ "${var_value}" =~ ^[a-zA-Z0-9]+([_-][a-zA-Z0-9]+)*$ ]]; then + if [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} '${var_name}' has valid value: '${var_value}'." + fi + else + echo "${ERR} '${var_name}' has invalid value: '${var_value}'." >&2 + return 1 + fi +} + +validate_numeric() { + if [[ "$#" -lt 2 ]]; then + echo -n "${ERR} Insufficient arguments provided for the " >&2 + echo "'${FUNCNAME[0]}' function." >&2 + exit 1 + fi + + local var_name="$1" + local var_value="$2" + + if ! [[ "${var_value}" =~ ^[0-9]+$ ]]; then + echo "${ERR} '${var_name}' has not numeric value: '${var_value}'." >&2 + return 1 + elif [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} '${var_name}' has numeric value: '${var_value}'." + fi +} + +# Function to validate non-empty arrays +validate_non_empty_array() { + if [[ "$#" -lt 2 ]]; then + echo -n "${ERR} Insufficient arguments provided for the " >&2 + echo "'${FUNCNAME[0]}' function." >&2 + exit 1 + fi + + local var_name="$1" + shift # Remove the first argument to treat the rest as an array + local -a arr=("$@") + + if [[ "${#arr[@]}" -eq 0 ]]; then + echo "${ERR} '${var_name}' is an empty array." >&2 + return 1 + elif [[ "${VERBOSE}" -eq 1 ]]; then + echo + echo "${YUP} '${var_name}' is a non-empty array: ${arr[*]}." + echo + fi +} + +# Function to validate no empty elements in an array +validate_no_empty_array_elements() { + if [[ "$#" -lt 2 ]]; then + echo -n "${ERR} Insufficient arguments provided for the " >&2 + echo "'${FUNCNAME[0]}' function." >&2 + exit 1 + fi + + local var_name="$1" + shift # Remove the first argument to treat the rest as an array + local -a arr=("$@") + + for element in "${arr[@]}"; do + if [[ -z "${element}" ]]; then + echo "${ERR} '${var_name}' contains an empty element." >&2 + return 1 + fi + done + + if [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} '${var_name}' has no empty elements." + fi +} + +# Function to validate repo names for each element in an array +validate_repo_names() { + if [[ "$#" -lt 2 ]]; then + echo -n "${ERR} Insufficient arguments provided for the " >&2 + echo "'${FUNCNAME[0]}' function." >&2 + exit 1 + fi + + local var_name="$1" + shift # Remove the first argument to treat the rest as an array + local -a arr=("$@") + + for element in "${arr[@]}"; do + if ! [[ "${element}" =~ ^[a-zA-Z0-9_.-]+$ ]]; then + echo -n "${ERR} '${var_name}' contains a non-valid element: " >&2 + echo -n "'${element}'. Repository names can only contain alphanumeric " >&2 + echo -n "characters, dot ('.'), hyphen-minus character ('-'), " >&2 + echo "and the underscore ('_')." >&2 + return 1 + fi + done + + if [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} All elements in '${var_name}' are valid." + fi +} + +# Function to validate user names for each element in an array +validate_user_names() { + # The array of user names can be empty, so we need to check if at least one + # element is provided. + if [[ "$#" -lt 1 ]]; then + echo -n "${ERR} Insufficient arguments provided for the " >&2 + echo "'${FUNCNAME[0]}' function." >&2 + exit 1 + fi + + local var_name="$1" + shift # Remove the first argument to treat the rest as an array + local -a arr=("$@") + + # Proceed only if the array is not empty + if [[ "${#arr[@]}" -eq 0 ]]; then + if [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} The array '${var_name}' is empty. No validation needed." + fi + return 0 + fi + + for element in "${arr[@]}"; do + if ! [[ "${element}" =~ ^[a-zA-Z0-9-]+$ ]]; then + echo -n "${ERR} '${var_name}' contains a non-valid element: " >&2 + echo -n "'${element}'. User names can be empty or contain alphanumeric" >&2 + echo " characters and hyphen-minus character ('-')." >&2 + return 1 + fi + done + + if [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} All elements in '${var_name}' are valid." + fi +} + +# Function to check for duplicate elements in an array +validate_no_duplicates() { + # Evaluation of this function needs 'set +u', otherwise will be terminated by + # 'set -u' + local is_set_nounset='' + + [[ "$-" == *u* ]] && is_set_nounset='true' || is_set_nounset='false' + set +u + + if [[ "$#" -lt 2 ]]; then + echo -n "${ERR} Insufficient arguments provided for the " >&2 + echo "'${FUNCNAME[0]}' function." >&2 + exit 1 + fi + + local var_name="$1" + shift # Remove the first argument to treat the rest as an array + local -a arr=("$@") + local -A arr_map=() + + for element in "${arr[@]}"; do + if [[ -n "${arr_map[${element}]}" ]]; then + echo "${ERR} '${var_name}' has duplicate elements: '${element}'." >&2 + return 1 + fi + arr_map["${element}"]=1 + done + + if [[ "${VERBOSE}" -eq 1 ]]; then + echo "${YUP} '${var_name}' has no duplicates." + fi + + "${is_set_nounset}" || set +u +} From 17176e3393626e84e39ee58dc6c193c36dc709ca Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 17:38:59 +0000 Subject: [PATCH 2/5] fix: address code review findings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rename swich_on_gh_pages → switch_on_gh_pages (spelling) - Fix typos: 'emapty' → 'empty', 'benn enabled' → 'been enabled' - Fix missing space in grep error message URL - Fix validate_no_duplicates logic: restore nounset with 'set -u' when it was originally set Agent-Logs-Url: https://github.com/the-soc-org/soc-cli/sessions/58540dc0-2442-4831-9073-4ffbb068a41a Co-authored-by: pierzcham <1788180+pierzcham@users.noreply.github.com> --- soc-lib-gh.sh | 10 +++++----- soc-lib.sh | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/soc-lib-gh.sh b/soc-lib-gh.sh index f4ca509..4bc7afb 100644 --- a/soc-lib-gh.sh +++ b/soc-lib-gh.sh @@ -285,7 +285,7 @@ check_repo_gh_pages_enabled() { # Enables GitHub Pages on the first target repository for the specified branch. # Usage: switch_on_gh_pages -swich_on_gh_pages() { +switch_on_gh_pages() { # Local variable declaration for the return value local has_pages @@ -387,7 +387,7 @@ get_file_sha() { if [[ -n "${sha}" ]]; then echo "${sha}" else - echo "${ERR} SHA for the '${source_file}' is emapty" + echo "${ERR} SHA for the '${source_file}' is empty" exit 1 fi else @@ -1150,7 +1150,7 @@ enable_team_review_assignment() { # Check whether the operation was successful if [[ $? -eq 0 ]]; then - echo -n "${OK} The review assignment has benn enabled " + echo -n "${OK} The review assignment has been enabled " echo "for the '${GH_TEAM_NAME}' team" else echo -n "${ERR} An error occurred while enabling the review " >&2 @@ -1954,8 +1954,8 @@ open() { # GitHub Pages is available in private repositories with GitHub Pro, GitHub # Team, GitHub Enterprise Cloud, and GitHub Enterprise Server. - echo 'Running swich_on_gh_pages()' - swich_on_gh_pages + echo 'Running switch_on_gh_pages()' + switch_on_gh_pages echo 'Running set_website_link()' set_repos_website } diff --git a/soc-lib.sh b/soc-lib.sh index dbbb158..a988e7d 100644 --- a/soc-lib.sh +++ b/soc-lib.sh @@ -111,7 +111,7 @@ check_if_grep_installed() { if ! command -v grep &> /dev/null; then echo -n "${ERR} 'grep' – a command-line utility for searching text " >&2 echo -n "that match a regular expression is not installed. " >&2 - echo "Install it to continue:https://www.gnu.org/software/grep/ " >&2 + echo "Install it to continue: https://www.gnu.org/software/grep/ " >&2 return 1 elif [[ "${VERBOSE}" -eq 1 ]]; then echo "${YUP} 'grep' is installed on your computer." @@ -368,5 +368,5 @@ validate_no_duplicates() { echo "${YUP} '${var_name}' has no duplicates." fi - "${is_set_nounset}" || set +u + "${is_set_nounset}" && set -u || true } From ca678ed467271ce9d89cdad0bd40cd9cc3cded8f Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 17:40:08 +0000 Subject: [PATCH 3/5] fix: address second-pass code review findings - Use explicit [[ == 'true' ]] comparison in validate_no_duplicates for clarity - Make SOC_PLATFORM_CLI parser in soc dispatcher handle single-quoted, double-quoted, and unquoted values Agent-Logs-Url: https://github.com/the-soc-org/soc-cli/sessions/58540dc0-2442-4831-9073-4ffbb068a41a Co-authored-by: pierzcham <1788180+pierzcham@users.noreply.github.com> --- soc | 6 +++++- soc-lib.sh | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/soc b/soc index 8d87d91..9a0926b 100755 --- a/soc +++ b/soc @@ -25,10 +25,14 @@ fi # Determine the platform CLI. We parse system_config.sh with grep/sed instead # of sourcing it to avoid readonly-variable conflicts when the config is later # sourced inside the individual command scripts. +# The generated system_config.sh always uses single quotes (e.g. +# readonly SOC_PLATFORM_CLI='gh'), but the parser also handles double-quoted +# and bare (unquoted) values for robustness. SOC_PLATFORM_CLI='gh' if [[ -f 'system_config.sh' ]]; then _val=$(grep "^readonly SOC_PLATFORM_CLI=" system_config.sh \ - | sed "s/^readonly SOC_PLATFORM_CLI='\(.*\)'/\1/" 2>/dev/null \ + | sed "s/^readonly SOC_PLATFORM_CLI=//; s/^['\"]//; s/['\"]$//" \ + 2>/dev/null \ || true) if [[ -n "${_val:-}" ]]; then SOC_PLATFORM_CLI="${_val}" diff --git a/soc-lib.sh b/soc-lib.sh index a988e7d..8c9f1f7 100644 --- a/soc-lib.sh +++ b/soc-lib.sh @@ -368,5 +368,5 @@ validate_no_duplicates() { echo "${YUP} '${var_name}' has no duplicates." fi - "${is_set_nounset}" && set -u || true + if [[ "${is_set_nounset}" == 'true' ]]; then set -u; fi } From e70bbc5368645287bb4b457606ff6cff9671ada5 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 17:51:41 +0000 Subject: [PATCH 4/5] docs: add full development plan markdown file Agent-Logs-Url: https://github.com/the-soc-org/soc-cli/sessions/818ba818-e9b4-489f-899d-5cab57278592 Co-authored-by: pierzcham <1788180+pierzcham@users.noreply.github.com> --- PLAN_ROZWOJU.md | 106 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 PLAN_ROZWOJU.md diff --git a/PLAN_ROZWOJU.md b/PLAN_ROZWOJU.md new file mode 100644 index 0000000..86cc980 --- /dev/null +++ b/PLAN_ROZWOJU.md @@ -0,0 +1,106 @@ +# Plan rozwoju `soc-cli` + +## Cel +Rozwinąć `soc-cli` jako stabilne, wieloplatformowe CLI do zarządzania kursami SoC, z naciskiem na jakość UX, bezpieczeństwo operacji administracyjnych i łatwość utrzymania. + +## Założenia strategiczne +- Zachować architekturę komend prefiksowanych platformą (`soc--`). +- Utrzymać kompatybilność z obecnym przepływem `gh soc ...`. +- Priorytetyzować bezpieczeństwo operacji destrukcyjnych (`delete`, `unsync`, `close`). +- Ułatwić adopcję narzędzia przez lepszą dokumentację i diagnostykę. + +## Etap 1 — Stabilizacja fundamentów (najwyższy priorytet) + +### 1.1 Walidacja i precheck +- [ ] Rozszerzyć `precheck` o pełną walidację konfiguracji (`user_config.sh`, `source_config.sh`, `system_config.sh`). +- [ ] Dodać czytelne komunikaty błędów z podpowiedzią naprawy. +- [ ] Dodać tryb `--verbose` oraz kod wyjścia per klasa błędu. + +### 1.2 Bezpieczeństwo operacji +- [ ] Wymusić dodatkowe potwierdzenia dla `delete`, `unsync`, `close`. +- [ ] Dodać tryb `--dry-run` dla komend modyfikujących stan organizacji/repo. +- [ ] Wprowadzić centralną obsługę rollbacku/częściowego niepowodzenia. + +### 1.3 Spójność CLI +- [ ] Ujednolicić format helpa i przykładów dla wszystkich komend. +- [ ] Ustandaryzować kody wyjścia i prefiksy logów (`INFO/WARN/ERROR`). +- [ ] Dodać walidację argumentów wejściowych na poziomie wrapperów komend. + +## Etap 2 — Utrzymanie i jakość kodu + +### 2.1 Testy i walidacja techniczna +- [ ] Dodać testy jednostkowe kluczowych funkcji bibliotek (`soc-lib.sh`, `soc-lib-gh.sh`). +- [ ] Dodać testy smoke dla głównych komend (`init`, `precheck`, `sync`, `open`, `assign`). +- [ ] Dodać automatyczne uruchamianie walidacji shellowej (np. lint + test) w CI. + +### 2.2 Refaktoryzacja i porządki +- [ ] Rozdzielić funkcje o wysokiej złożoności na mniejsze moduły. +- [ ] Ujednolicić nazewnictwo funkcji i stałych konfiguracyjnych. +- [ ] Zmniejszyć duplikację logiki pomiędzy komendami administracyjnymi. + +## Etap 3 — Użyteczność dla prowadzących kurs + +### 3.1 Lepsza obserwowalność +- [ ] Rozszerzyć `status` o podsumowanie: liczba repo, przypisań, zaproszeń i niezgodności. +- [ ] Rozszerzyć `log` o metadane uruchomienia (czas, użytkownik, wersja CLI). +- [ ] Dodać prosty eksport raportów do CSV/JSON z jednym formatem schematu. + +### 3.2 Ergonomia pracy +- [ ] Dodać interaktywne potwierdzenia z możliwością pominięcia (`--yes`) dla automatyzacji. +- [ ] Dodać czytelne komunikaty postępu dla dłuższych operacji (`sync`, `open`). +- [ ] Dodać szybkie komendy diagnostyczne (`soc doctor` / rozszerzony `precheck`). + +## Etap 4 — Rozszerzalność platformowa + +### 4.1 Interfejs platform +- [ ] Zdefiniować kontrakt dla nowych platform (`soc--*` + wspólne API funkcji). +- [ ] Przygotować szablon startowy nowej platformy (minimalny zestaw komend + dokumentacja). +- [ ] Dodać test kompatybilności międzyplatformowej dla dispatcher-a `soc`. + +### 4.2 Wersjonowanie i release +- [ ] Wprowadzić semantyczne wersjonowanie i changelog wydania. +- [ ] Dodać proces release z checklistą kompatybilności i migracji. + +## Etap 5 — Dokumentacja i adopcja + +### 5.1 Dokumentacja użytkownika +- [ ] Przebudować README pod scenariusz „quick start do pierwszego kursu”. +- [ ] Dodać sekcję „najczęstsze problemy i rozwiązania”. +- [ ] Dodać pełną tabelę komend z parametrami i przykładami. + +### 5.2 Dokumentacja techniczna +- [ ] Rozszerzyć ADR o standard tworzenia nowych komend i modułów. +- [ ] Dodać przewodnik contributorski: lokalny workflow, testy, standard zmian. + +## Proponowany backlog Issue (do utworzenia i przypisania) + +### Epic A — Stabilność i bezpieczeństwo +- [ ] ISSUE: Rozszerzony precheck walidujący wszystkie pliki konfiguracyjne. +- [ ] ISSUE: `--dry-run` dla komend modyfikujących stan zasobów GitHub. +- [ ] ISSUE: Dodatkowe potwierdzenia i mechanizm bezpiecznika dla `delete/unsync`. +- [ ] ISSUE: Standaryzacja kodów wyjścia i formatu błędów. + +### Epic B — Jakość i utrzymanie +- [ ] ISSUE: Testy jednostkowe dla `soc-lib.sh`. +- [ ] ISSUE: Testy jednostkowe dla `soc-lib-gh.sh`. +- [ ] ISSUE: Testy smoke dla ścieżki `init -> precheck -> sync`. +- [ ] ISSUE: Integracja lint/test w CI. + +### Epic C — UX i operacyjność +- [ ] ISSUE: Rozbudowany `status` z metrykami kursu. +- [ ] ISSUE: Ujednolicony, czytelny output logów i tryb verbose. +- [ ] ISSUE: Raport eksportu CSV/JSON. +- [ ] ISSUE: Quick diagnostics (`doctor`) dla prowadzących. + +### Epic D — Rozszerzalność i release +- [ ] ISSUE: Kontrakt interfejsu platformowego dla `soc--*`. +- [ ] ISSUE: Szablon implementacji nowej platformy. +- [ ] ISSUE: SemVer + changelog + proces release. + +## Kryteria gotowości (Definition of Done) +- [ ] Każde zadanie ma kryteria akceptacji i właściciela. +- [ ] Zmiany są pokryte testami adekwatnymi do ryzyka. +- [ ] Dokumentacja użytkowa i techniczna jest zaktualizowana. +- [ ] Operacje wysokiego ryzyka mają zabezpieczenia i czytelny rollback. +- [ ] Brak regresji dla podstawowych komend (`init`, `sync`, `open`, `assign`, `status`). + From 0516f5962b85c4f4c3e189e16f4ef0e677f22fbb Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 14 May 2026 17:59:53 +0000 Subject: [PATCH 5/5] docs: add Propozycje Issues markdown file Agent-Logs-Url: https://github.com/the-soc-org/soc-cli/sessions/41f7f023-9d6c-439d-8016-bbf975fbcada Co-authored-by: pierzcham <1788180+pierzcham@users.noreply.github.com> --- PROPOZYCJE_ISSUES_DLA_SOC_CLI.md | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 PROPOZYCJE_ISSUES_DLA_SOC_CLI.md diff --git a/PROPOZYCJE_ISSUES_DLA_SOC_CLI.md b/PROPOZYCJE_ISSUES_DLA_SOC_CLI.md new file mode 100644 index 0000000..c99c652 --- /dev/null +++ b/PROPOZYCJE_ISSUES_DLA_SOC_CLI.md @@ -0,0 +1,24 @@ +# Propozycje Issues dla soc-cli + +## Epic A — Stabilność i bezpieczeństwo +- [ ] ISSUE: Rozszerzony precheck walidujący wszystkie pliki konfiguracyjne. +- [ ] ISSUE: `--dry-run` dla komend modyfikujących stan zasobów GitHub. +- [ ] ISSUE: Dodatkowe potwierdzenia i mechanizm bezpiecznika dla `delete/unsync`. +- [ ] ISSUE: Standaryzacja kodów wyjścia i formatu błędów. + +## Epic B — Jakość i utrzymanie +- [ ] ISSUE: Testy jednostkowe dla `soc-lib.sh`. +- [ ] ISSUE: Testy jednostkowe dla `soc-lib-gh.sh`. +- [ ] ISSUE: Testy smoke dla ścieżki `init -> precheck -> sync`. +- [ ] ISSUE: Integracja lint/test w CI. + +## Epic C — UX i operacyjność +- [ ] ISSUE: Rozbudowany `status` z metrykami kursu. +- [ ] ISSUE: Ujednolicony, czytelny output logów i tryb verbose. +- [ ] ISSUE: Raport eksportu CSV/JSON. +- [ ] ISSUE: Quick diagnostics (`doctor`) dla prowadzących. + +## Epic D — Rozszerzalność i release +- [ ] ISSUE: Kontrakt interfejsu platformowego dla `soc--*`. +- [ ] ISSUE: Szablon implementacji nowej platformy. +- [ ] ISSUE: SemVer + changelog + proces release.