diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..2ddc758 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,4 @@ +FROM python:3.13-slim +COPY entrypoint.py /entrypoint.py +RUN pip install "requests>=2.32.3" +ENTRYPOINT [ "python3", "/entrypoint.py" ] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..830f6f8 --- /dev/null +++ b/README.md @@ -0,0 +1,43 @@ +# DevSkiller TalentScore - Upload task action + +This GitHub Action uploads custom programming tasks to the DevSkiller TalentScore platform. It allows you to easily integrate the upload process into your CI/CD pipeline. + +## Inputs + +### `api_key` +**Required**: The TalentScore API key. This key is needed to authenticate the upload request. + +### `path` +**Required**: The path to the programming task directory that contains the task source code to upload. + +### `id` +**Optional**: The ID of the programming task you want to update on the TalentScore platform. If not provided, the code task directory should contain a [metadata.yaml file](metadata-file-structure.md). + +### `publish` +**Optional**: If set to `true` (default), the task will be published automatically after a successful build. + +## Example + +```yaml +name: Sample task upload + +on: + push: + branches: + - master + +jobs: + upload-task: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v2 + - uses: Devskiller/talentscore-upload-task-action@v1.0.0 + with: + api_key: ${{ secrets.TALENTSCORE_API_KEY }} + id: fe3217a6-e085-47dd-afff-025be5355d87 + path: ./src +``` + +## How to prepare a custom programming task +Please see: [Creating custom tasks](https://help.devskiller.com/space/TSG/2893873179/Creating+Custom+Tasks) \ No newline at end of file diff --git a/action.yml b/action.yml new file mode 100644 index 0000000..a24ca8b --- /dev/null +++ b/action.yml @@ -0,0 +1,25 @@ +name: Upload the TalentScore task +description: Upload your custom tasks to the DevSkiller TalentScore platform +author: DevSkiller +branding: + icon: upload-cloud + color: blue + +inputs: + api_key: + description: "TalentScore API key" + required: true + id: + description: "The ID of the programming task to upload" + required: true + path: + description: "The path of the programming task directory to upload" + required: true + publish: + description: "Whether to publish the task if it builds successfully" + required: false + default: 'true' + +runs: + using: docker + image: Dockerfile \ No newline at end of file diff --git a/entrypoint.py b/entrypoint.py new file mode 100755 index 0000000..5b71d43 --- /dev/null +++ b/entrypoint.py @@ -0,0 +1,120 @@ +import os +import shutil +import requests +import time + +TASKS_API = os.environ.get("PLATFORM_URL", "https://api.devskiller.com") + "/tasks" +BUILD_CHECK_MAX_ATTEMPTS = 36 + +input_api_key = os.environ["INPUT_API_KEY"] +input_id = os.environ["INPUT_ID"] +input_path = os.environ["INPUT_PATH"] +input_publish = os.environ["INPUT_PUBLISH"] + +def validate_input_path(): + if not os.path.isdir(input_path): + print("::error::PATH parameter is not a directory") + exit(1) + +def create_zip_archive(): + shutil.make_archive("task", "zip", input_path) + +def upload_task(): + print(f"::info::Uploading the task") + with open("task.zip", "rb") as f: + upload_url = f"{TASKS_API}/{input_id}/zip" if input_id else f"{TASKS_API}/zip" + + upload_response = requests.put( + upload_url, + data=f.read(), + headers={"Content-Type": "application/zip", "Devskiller-Api-Key": input_api_key}, + ) + + if upload_response.status_code != 202: + print(f"::error::Upload failed with status code: {upload_response.status_code}, response: {upload_response.text}") + exit(1) + + return upload_response.json().get('taskId') + +def check_build_status(taskId): + print(f"::info::Checking the build status for task with id: {taskId}") + for _ in range(BUILD_CHECK_MAX_ATTEMPTS): + status_response = requests.get(f"{TASKS_API}/{taskId}", headers={"Devskiller-Api-Key": input_api_key}) + if status_response.status_code == 200: + status_data = status_response.json() + if status_data.get('type') == 'CODE_REVIEW': + print("::info::Code review task detected therefore skipping the build check") + return False, status_data + + if status_data.get('buildStatus') is not None: + print(f"::info::Build status is: {status_data.get('buildStatus')}") + if status_data.get('buildLog'): + print("Build log:") + print(status_data.get('buildLog')) + return True, status_data + time.sleep(10) + else: + print(f"::error::Build status check failed with status code: {status_response.status_code}, response: {status_response.text}") + exit(1) + + print("::error::Timeout waiting for build status") + exit(1) + +def format_summary_test_section(section_name, test_data): + markdown = f"\n\n### {section_name}\n" + if len(test_data) == 0: + markdown += "No tests.\n" + else: + for test_suite, tests in test_data.items(): + markdown += f"- {test_suite}:\n" + for test_name, test_result in tests.items(): + markdown += f" - {test_name}: **{'✅' if test_result == 'PASS' else '❌'}**\n" + + return markdown + +def write_summary(status_data): + with open(os.environ['GITHUB_STEP_SUMMARY'], 'a') as f: + markdown = f"## Build Status: {status_data.get('buildStatus')} {'✅' if status_data.get('buildStatus') == 'TEST_FAILURE' else '❌'}" + markdown += format_summary_test_section("Candidate Tests", status_data.get('candidateTests', {})) + markdown += format_summary_test_section("Verification Tests", status_data.get('verificationTests', {})) + + if status_data.get('buildLog'): + markdown += "\n### Build log:\n" + markdown += "```\n" + markdown += status_data.get('buildLog', '') + markdown += "\n```\n" + f.write(markdown) + +def verify_build_status(status_data): + if status_data.get('buildStatus') != 'TEST_FAILURE': + print("::error::The initial state of the task should be compiling, but with broken tests") + exit(1) + + if not status_data.get('verificationTests', {}): + print("::error::You are missing the verification tests") + exit(1) + +def publish_task(taskId): + print(f"::info::Publishing the task with ID: {taskId}") + response = requests.post( + f"{TASKS_API}/{taskId}/publish", + headers={"Devskiller-Api-Key": input_api_key}, + ) + + if response.status_code != 204: + print(f"::error::Publication failed with status code: {response.status_code}, response: {response.text}") + exit(1) + +def main(): + validate_input_path() + create_zip_archive() + taskId = upload_task() + build_executed, status_data = check_build_status(taskId) + if build_executed: + write_summary(status_data) + verify_build_status(status_data) + if input_publish: + publish_task(taskId) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/metadata-file-structure.md b/metadata-file-structure.md new file mode 100644 index 0000000..cd5f43b --- /dev/null +++ b/metadata-file-structure.md @@ -0,0 +1,29 @@ +# Metadata file structure +The file stores programming task details in the form of YAML key-value pairs, with the following fields: + +- **uuid**: ID of the task. With it you will be able to update tasks. You can use the existing one or generate an unique value using [generators](https://www.uuidgenerator.net/) +- **title**: title of a task +- **question**: question which will be presented to the candidate +- **type**: `PROGRAMMING` or `CODE_REVIEW` +- **difficulty**: difficulty level, one of `EASY`, `MEDIUM`, `HARD` +- **tags**: task tags, ie. what kind of knowledge this questions tests +- **points**: default number of points for this task +- **duration**: default time duration in the ISO 8601 duration format +- **projectType**: technology associated with the task, e.g.: `CMAKE`, `NODEJS`, `MAVEN` +- **projectTypeVersion**: specific version of the technology, e.g.: `NODEJS16`, `GCC11` + +## Example + +```yaml +uuid: aee4ae94-5709-4917-8dde-fa54b91f84cf +title: Java | Sample Calculator task +question: |- + Your task is to create a simple calculator capable of adding, subtracting, multiplying and dividing two numbers. +type: PROGRAMMING +difficulty: EASY +tags: ['C++', 'Basics'] +points: 20 +duration: PT1H +projectType: CMAKE +projectTypeVersion: GCC11 +``` \ No newline at end of file