diff --git a/.github/workflows/lint_and_test.yml b/.github/workflows/lint_and_test.yml index b75b3b0..fd0fc25 100644 --- a/.github/workflows/lint_and_test.yml +++ b/.github/workflows/lint_and_test.yml @@ -1,28 +1,38 @@ -name: Lint and Test +name: Run VSCode Extension Tests + on: push: - branches: - - master + branches: [main] pull_request: - branches: - - master + branches: [main] jobs: test: - runs-on: ubuntu-latest - + strategy: + matrix: + node-version: [22.x] + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} steps: - name: Checkout code uses: actions/checkout@v4 - - name: Setup Node.js + - name: Setup Node.js environment uses: actions/setup-node@v4 with: - node-version: "20" + node-version: ${{ matrix.node-version }} cache: "npm" - name: Install dependencies run: npm ci - # Runs both the test and lint commands - - name: Run tests + + - name: Compile + run: npm run compile + + - name: Run tests (Linux) + run: xvfb-run -a npm test + if: runner.os == 'Linux' + + - name: Run tests (Windows/Mac) run: npm test + if: runner.os != 'Linux' diff --git a/.vscode-test.mjs b/.vscode-test.mjs index b62ba25..49fac78 100644 --- a/.vscode-test.mjs +++ b/.vscode-test.mjs @@ -1,5 +1,5 @@ import { defineConfig } from '@vscode/test-cli'; export default defineConfig({ - files: 'out/test/**/*.test.js', + files: 'out/test/**/*.test.js', }); diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 186459d..5906abf 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,8 +1,8 @@ { - // See http://go.microsoft.com/fwlink/?LinkId=827846 - // for the documentation about the extensions.json format - "recommendations": [ - "dbaeumer.vscode-eslint", - "ms-vscode.extension-test-runner" - ] + // See http://go.microsoft.com/fwlink/?LinkId=827846 + // for the documentation about the extensions.json format + "recommendations": [ + "dbaeumer.vscode-eslint", + "ms-vscode.extension-test-runner" + ] } diff --git a/.vscode/launch.json b/.vscode/launch.json index 8880465..a0ca3cb 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -3,19 +3,15 @@ // Hover to view descriptions of existing attributes. // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 { - "version": "0.2.0", - "configurations": [ - { - "name": "Run Extension", - "type": "extensionHost", - "request": "launch", - "args": [ - "--extensionDevelopmentPath=${workspaceFolder}" - ], - "outFiles": [ - "${workspaceFolder}/out/**/*.js" - ], - "preLaunchTask": "${defaultBuildTask}" - } - ] + "version": "0.2.0", + "configurations": [ + { + "name": "Run Extension", + "type": "extensionHost", + "request": "launch", + "args": ["--extensionDevelopmentPath=${workspaceFolder}"], + "outFiles": ["${workspaceFolder}/out/**/*.js"], + "preLaunchTask": "${defaultBuildTask}" + } + ] } diff --git a/.vscode/settings.json b/.vscode/settings.json index afdab66..ffeaf91 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,11 +1,11 @@ // Place your settings in this file to overwrite default and user settings. { - "files.exclude": { - "out": false // set this to true to hide the "out" folder with the compiled JS files - }, - "search.exclude": { - "out": true // set this to false to include "out" folder in search results - }, - // Turn off tsc task auto detection since we have the necessary tasks as npm scripts - "typescript.tsc.autoDetect": "off" + "files.exclude": { + "out": false // set this to true to hide the "out" folder with the compiled JS files + }, + "search.exclude": { + "out": true // set this to false to include "out" folder in search results + }, + // Turn off tsc task auto detection since we have the necessary tasks as npm scripts + "typescript.tsc.autoDetect": "off" } diff --git a/.vscode/tasks.json b/.vscode/tasks.json index 3b17e53..078ff7e 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -1,20 +1,20 @@ // See https://go.microsoft.com/fwlink/?LinkId=733558 // for the documentation about the tasks.json format { - "version": "2.0.0", - "tasks": [ - { - "type": "npm", - "script": "watch", - "problemMatcher": "$tsc-watch", - "isBackground": true, - "presentation": { - "reveal": "never" - }, - "group": { - "kind": "build", - "isDefault": true - } - } - ] + "version": "2.0.0", + "tasks": [ + { + "type": "npm", + "script": "watch", + "problemMatcher": "$tsc-watch", + "isBackground": true, + "presentation": { + "reveal": "never" + }, + "group": { + "kind": "build", + "isDefault": true + } + } + ] } diff --git a/CHANGELOG.md b/CHANGELOG.md index c9a250a..1a7244b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,4 +6,4 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how ## [Unreleased] -- Initial release \ No newline at end of file +- Initial release diff --git a/README.md b/README.md index a816d42..73f2092 100644 --- a/README.md +++ b/README.md @@ -1,21 +1,25 @@ # Submitty Extension for VS Code ## Overview + The Submitty Extension for VS Code integrates the Submitty grading system directly into Visual Studio Code, allowing users to easily submit assignments, view grades, and interact with their courses without leaving the editor. ## Features + - **Assignment Submission**: Submit assignments directly from VS Code. - **Grade Retrieval**: View grades and feedback within the editor. - **Course Management**: Access course information and assignment details. - **Error & Feedback Display**: Get inline feedback on submissions. ## Setup + 1. Open the **Submitty Extension**. 2. Enter your **Submitty server URL**. 3. Authenticate using your **username and password**. 4. Select your **course** from the available list. ## Usage + - **Submit an Assignment**: 1. Open the relevant assignment file. 2. Click on the HW you want graded. @@ -24,9 +28,11 @@ The Submitty Extension for VS Code integrates the Submitty grading system direct - Open the Submitty panel to view assignment grades and instructor feedback. ## Requirements + - A valid Submitty account. ## Roadmap + - [ ] Allow users to access homeowrk - [ ] Figure out a way to grade homework and display results back to users - [ ] Display test results with feedback diff --git a/eslint.config.mjs b/eslint.config.mjs index 1645563..e0906fa 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -9,7 +9,15 @@ export default defineConfig([ // --- 1. Global Ignores --- // Files and directories to ignore across the entire project { - ignores: ['out/**', 'dist/**', '**/*.d.ts', 'node_modules/**', '.vscode-test/**', '.vscode-test.mjs', 'eslint.config.mjs'], + ignores: [ + 'out/**', + 'dist/**', + '**/*.d.ts', + 'node_modules/**', + '.vscode-test/**', + '.vscode-test.mjs', + 'eslint.config.mjs', + ], }, // --- 2. Base Configurations (Applied to ALL files by default) --- @@ -31,7 +39,7 @@ export default defineConfig([ parserOptions: { projectService: true, }, - } + }, }, // --- 3. Configuration for VS Code Extension (Node.js/TypeScript) --- @@ -66,10 +74,10 @@ export default defineConfig([ format: ['camelCase', 'PascalCase'], }, ], - 'curly': 'warn', // Require curly braces for all control statements - 'eqeqeq': 'warn', // Require the use of '===' and '!==' + curly: 'warn', // Require curly braces for all control statements + eqeqeq: 'warn', // Require the use of '===' and '!==' 'no-throw-literal': 'warn', // Disallow throwing literals as exceptions - 'semi': 'off', // Let Prettier handle semicolons (or enforce no semicolons) + semi: 'off', // Let Prettier handle semicolons (or enforce no semicolons) '@typescript-eslint/no-floating-promises': 'error', // Good for async operations '@typescript-eslint/explicit-function-return-type': [ 'warn', @@ -90,5 +98,4 @@ export default defineConfig([ '@typescript-eslint/no-explicit-any': 'off', // Or 'warn' depending on your preference }, }, - -]); \ No newline at end of file +]); diff --git a/package.json b/package.json index 4766aab..1a9a1cf 100644 --- a/package.json +++ b/package.json @@ -93,4 +93,4 @@ "axios": "^1.7.8", "keytar": "^7.9.0" } -} \ No newline at end of file +} diff --git a/src/extension.ts b/src/extension.ts index 09c7bfb..26ebc24 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,16 +2,63 @@ import * as vscode from 'vscode'; import { SidebarProvider } from './sidebarProvider'; import { ApiService } from './services/apiService'; import { TestingService } from './services/testingService'; +import { GitService } from './services/gitService'; +import { AuthService } from './services/authService'; +import { CourseRepoResolver } from './services/courseRepoResolver'; +import type { Gradable } from './interfaces/Gradables'; export function activate(context: vscode.ExtensionContext): void { - const apiService = ApiService.getInstance(context, ''); - const testingService = new TestingService(context, apiService); - const sidebarProvider = new SidebarProvider(context, testingService); + const apiService = ApiService.getInstance(context, ''); + const testingService = new TestingService(context, apiService); + const gitService = new GitService(); + const authService = AuthService.getInstance(context); + const sidebarProvider = new SidebarProvider( + context, + testingService, + gitService + ); - context.subscriptions.push( - vscode.window.registerWebviewViewProvider('submittyWebview', sidebarProvider) - ); + context.subscriptions.push( + vscode.window.registerWebviewViewProvider( + 'submittyWebview', + sidebarProvider + ) + ); + // Preload gradables into the Test Explorer when the workspace appears + // to be a course-tied repo. + void (async () => { + try { + await authService.initialize(); + const resolver = new CourseRepoResolver( + apiService, + authService, + gitService + ); + const courseContext = await resolver.resolveCourseContextFromRepo(); + if (!courseContext) { + return; + } + + const gradablesResponse = await apiService.fetchGradables( + courseContext.courseId, + courseContext.term + ); + const gradables = Object.values(gradablesResponse.data); + + for (const g of gradables) { + testingService.addGradeable( + courseContext.term, + courseContext.courseId, + g.id, + g.title || g.id + ); + } + } catch (e) { + const err = e instanceof Error ? e.message : String(e); + console.warn(`Failed to preload gradables: ${err}`); + } + })(); } -export function deactivate() { } \ No newline at end of file +export function deactivate() {} diff --git a/src/interfaces/AutoGraderDetails.ts b/src/interfaces/AutoGraderDetails.ts index 7a94b18..d654df4 100644 --- a/src/interfaces/AutoGraderDetails.ts +++ b/src/interfaces/AutoGraderDetails.ts @@ -1,42 +1,41 @@ export interface AutoGraderDetails { - status: string - data: AutoGraderDetailsData + status: string; + data: AutoGraderDetailsData; } export interface AutoGraderDetailsData { - is_queued: boolean - queue_position: number - is_grading: boolean - has_submission: boolean - autograding_complete: boolean - has_active_version: boolean - highest_version: number - total_points: number - total_percent: number - test_cases: TestCase[] + is_queued: boolean; + queue_position: number; + is_grading: boolean; + has_submission: boolean; + autograding_complete: boolean; + has_active_version: boolean; + highest_version: number; + total_points: number; + total_percent: number; + test_cases: TestCase[]; } export interface TestCase { - name: string - details: string - is_extra_credit: boolean - points_available: number - has_extra_results: boolean - points_received: number - testcase_message: string - autochecks: Autocheck[] + name: string; + details: string; + is_extra_credit: boolean; + points_available: number; + has_extra_results: boolean; + points_received: number; + testcase_message: string; + autochecks: Autocheck[]; } export interface Autocheck { - description: string - messages: Message[] - diff_viewer: Record - expected: string - actual: string + description: string; + messages: Message[]; + diff_viewer: Record; + expected: string; + actual: string; } export interface Message { - message: string - type: string + message: string; + type: string; } - diff --git a/src/interfaces/Courses.ts b/src/interfaces/Courses.ts index 9d60f51..354aaf8 100644 --- a/src/interfaces/Courses.ts +++ b/src/interfaces/Courses.ts @@ -1,8 +1,8 @@ export interface Course { - semester: string; - title: string; - display_name: string; - display_semester: string; - user_group: number; - registration_section: string; -} \ No newline at end of file + semester: string; + title: string; + display_name: string; + display_semester: string; + user_group: number; + registration_section: string; +} diff --git a/src/interfaces/Gradables.ts b/src/interfaces/Gradables.ts index eef1dad..a8dff20 100644 --- a/src/interfaces/Gradables.ts +++ b/src/interfaces/Gradables.ts @@ -1,18 +1,18 @@ export interface Gradable { - id: string - title: string - instructions_url: string - gradeable_type: string - syllabus_bucket: string - section: number - section_name: string - due_date: DueDate - vcs_repository: string - vcs_subdirectory: string + id: string; + title: string; + instructions_url: string; + gradeable_type: string; + syllabus_bucket: string; + section: number; + section_name: string; + due_date: DueDate; + vcs_repository: string; + vcs_subdirectory: string; } export interface DueDate { - date: string - timezone_type: number - timezone: string + date: string; + timezone_type: number; + timezone: string; } diff --git a/src/interfaces/Responses.ts b/src/interfaces/Responses.ts index 19db2d4..6f6c2e0 100644 --- a/src/interfaces/Responses.ts +++ b/src/interfaces/Responses.ts @@ -1,20 +1,21 @@ -import { Course } from "./Courses"; -import { Gradable } from "./Gradables"; - +import { Course } from './Courses'; +import { Gradable } from './Gradables'; export interface ApiResponse { - status: string; - data: T; - message?: string; + status: string; + data: T; + message?: string; } export type CourseResponse = ApiResponse<{ - unarchived_courses: Course[]; - dropped_courses: Course[]; + unarchived_courses: Course[]; + dropped_courses: Course[]; }>; export type LoginResponse = ApiResponse<{ - token: string; + token: string; }>; -export type GradableResponse = ApiResponse; \ No newline at end of file +export type GradableResponse = ApiResponse<{ + [key: string]: Gradable; +}>; diff --git a/src/services/apiClient.ts b/src/services/apiClient.ts index dc0ce93..d3fd209 100644 --- a/src/services/apiClient.ts +++ b/src/services/apiClient.ts @@ -3,131 +3,145 @@ import axios, { AxiosInstance, AxiosRequestConfig, AxiosResponse } from 'axios'; export class ApiClient { - private client: AxiosInstance; - - constructor(baseURL: string = '', defaultHeaders: Record = {}) { - this.client = axios.create({ - baseURL, - headers: { - 'Content-Type': 'application/json', - ...defaultHeaders, - }, - timeout: 30000, // 30 seconds timeout - }); - - // Request interceptor - this.client.interceptors.request.use( - (config) => { - // Add any request logging or modification here - return config; - }, - (error: Error) => { - return Promise.reject(new Error(error.message || 'Request failed')); - } - ); - - // Response interceptor - this.client.interceptors.response.use( - (response) => { - return response; - }, - (error: Error) => { - // Handle common errors here - return Promise.reject(new Error(error.message || 'Response failed')); - } - ); - } - - /** - * Set the base URL for all requests - */ - setBaseURL(baseURL: string): void { - this.client.defaults.baseURL = baseURL; - } - - /** - * Set default headers for all requests - */ - setDefaultHeaders(headers: Record): void { - this.client.defaults.headers.common = { - ...this.client.defaults.headers.common, - ...headers, - }; - } - - /** - * Set the Authorization token for all requests - */ - setToken(token: string): void { - this.client.defaults.headers.common['Authorization'] = `${token}`; - } - - /** - * GET request - */ - async get(url: string, config?: AxiosRequestConfig): Promise> { - return this.client.get(url, config); - } - - /** - * POST request - */ - async post( - url: string, - data?: any, - config?: AxiosRequestConfig - ): Promise> { - return this.client.post(url, data, config); - } - - /** - * PUT request - */ - async put( - url: string, - data?: any, - config?: AxiosRequestConfig - ): Promise> { - return this.client.put(url, data, config); - } - - /** - * PATCH request - */ - async patch( - url: string, - data?: any, - config?: AxiosRequestConfig - ): Promise> { - return this.client.patch(url, data, config); - } - - /** - * DELETE request - */ - async delete(url: string, config?: AxiosRequestConfig): Promise> { - return this.client.delete(url, config); - } - - /** - * HEAD request - */ - async head(url: string, config?: AxiosRequestConfig): Promise> { - return this.client.head(url, config); - } - - /** - * OPTIONS request - */ - async options(url: string, config?: AxiosRequestConfig): Promise> { - return this.client.options(url, config); - } - - /** - * Get the underlying axios instance for advanced usage - */ - getAxiosInstance(): AxiosInstance { - return this.client; - } + private client: AxiosInstance; + + constructor( + baseURL: string = '', + defaultHeaders: Record = {} + ) { + this.client = axios.create({ + baseURL, + headers: { + 'Content-Type': 'application/json', + ...defaultHeaders, + }, + timeout: 30000, // 30 seconds timeout + }); + + // Request interceptor + this.client.interceptors.request.use( + config => { + // Add any request logging or modification here + return config; + }, + (error: Error) => { + return Promise.reject(new Error(error.message || 'Request failed')); + } + ); + + // Response interceptor + this.client.interceptors.response.use( + response => { + return response; + }, + (error: Error) => { + // Handle common errors here + return Promise.reject(new Error(error.message || 'Response failed')); + } + ); + } + + /** + * Set the base URL for all requests + */ + setBaseURL(baseURL: string): void { + this.client.defaults.baseURL = baseURL; + } + + /** + * Set default headers for all requests + */ + setDefaultHeaders(headers: Record): void { + this.client.defaults.headers.common = { + ...this.client.defaults.headers.common, + ...headers, + }; + } + + /** + * Set the Authorization token for all requests + */ + setToken(token: string): void { + this.client.defaults.headers.common['Authorization'] = `${token}`; + } + + /** + * GET request + */ + async get( + url: string, + config?: AxiosRequestConfig + ): Promise> { + return this.client.get(url, config); + } + + /** + * POST request + */ + async post( + url: string, + data?: any, + config?: AxiosRequestConfig + ): Promise> { + return this.client.post(url, data, config); + } + + /** + * PUT request + */ + async put( + url: string, + data?: any, + config?: AxiosRequestConfig + ): Promise> { + return this.client.put(url, data, config); + } + + /** + * PATCH request + */ + async patch( + url: string, + data?: any, + config?: AxiosRequestConfig + ): Promise> { + return this.client.patch(url, data, config); + } + + /** + * DELETE request + */ + async delete( + url: string, + config?: AxiosRequestConfig + ): Promise> { + return this.client.delete(url, config); + } + + /** + * HEAD request + */ + async head( + url: string, + config?: AxiosRequestConfig + ): Promise> { + return this.client.head(url, config); + } + + /** + * OPTIONS request + */ + async options( + url: string, + config?: AxiosRequestConfig + ): Promise> { + return this.client.options(url, config); + } + + /** + * Get the underlying axios instance for advanced usage + */ + getAxiosInstance(): AxiosInstance { + return this.client; + } } - diff --git a/src/services/apiService.ts b/src/services/apiService.ts index b7f6809..0706c47 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -2,169 +2,214 @@ import * as vscode from 'vscode'; import { ApiClient } from './apiClient'; - -import { CourseResponse, LoginResponse, GradableResponse } from '../interfaces/Responses'; +import { + CourseResponse, + LoginResponse, + GradableResponse, +} from '../interfaces/Responses'; import { AutoGraderDetails } from '../interfaces/AutoGraderDetails'; +function getErrorMessage(error: unknown, fallback: string): string { + if (error instanceof Error) { + return error.message || fallback; + } + if (typeof error === 'object' && error) { + const maybeAxiosError = error as { + response?: { data?: { message?: unknown } }; + }; + const msg = maybeAxiosError.response?.data?.message; + if (typeof msg === 'string' && msg.trim()) { + return msg; + } + } + return fallback; +} export class ApiService { - private client: ApiClient; - private static instance: ApiService; + private client: ApiClient; + private static instance: ApiService; + + constructor( + private context: vscode.ExtensionContext, + apiBaseUrl: string + ) { + this.client = new ApiClient(apiBaseUrl); + } + + // set token for local api client + setAuthorizationToken(token: string): void { + this.client.setToken(token); + } + + // set base URL for local api client + setBaseUrl(baseUrl: string): void { + this.client.setBaseURL(baseUrl); + } + + /** + * Login to the Submitty API + */ + async login(userId: string, password: string): Promise { + try { + const response = await this.client.post( + '/api/token', + { + user_id: userId, + password: password, + }, + { + headers: { 'Content-Type': 'multipart/form-data' }, + } + ); - constructor(private context: vscode.ExtensionContext, apiBaseUrl: string) { - this.client = new ApiClient(apiBaseUrl); + const token: string = response.data.data.token; + return token; + } catch (error: unknown) { + throw new Error(getErrorMessage(error, 'Login failed.')); } - - // set token for local api client - setAuthorizationToken(token: string) { - this.client.setToken(token); + } + + async fetchMe(): Promise { + try { + const response = await this.client.get('/api/me'); + return response.data; + } catch (error: unknown) { + throw new Error(getErrorMessage(error, 'Failed to fetch me.')); } - - // set base URL for local api client - setBaseUrl(baseUrl: string) { - this.client.setBaseURL(baseUrl); + } + + /** + * Fetch all courses for the authenticated user + */ + async fetchCourses(_token?: string): Promise { + try { + const response = await this.client.get('/api/courses'); + return response.data; + } catch (error: unknown) { + console.error('Error fetching courses:', error); + throw new Error(getErrorMessage(error, 'Failed to fetch courses.')); } - - /** - * Login to the Submitty API - */ - async login(userId: string, password: string): Promise { - try { - const response = await this.client.post( - '/api/token', - { - user_id: userId, - password: password, - }, - { - headers: { 'Content-Type': 'multipart/form-data' }, - } - ); - - const token: string = response.data.data.token; - return token; - } catch (error: any) { - throw new Error(error.response?.data?.message || error.message || 'Login failed.'); - } + } + + async fetchGradables( + courseId: string, + term: string + ): Promise { + try { + const url = `/api/${term}/${courseId}/gradeables`; + const response = await this.client.get(url); + return response.data; + } catch (error: unknown) { + console.error('Error fetching gradables:', error); + throw new Error(getErrorMessage(error, 'Failed to fetch gradables.')); } - - async fetchMe(): Promise { - try { - const response = await this.client.get('/api/me'); - return response.data; - } catch (error: any) { - throw new Error(error.response?.data?.message || 'Failed to fetch me.'); - } + } + + /** + * Fetch grade details for a specific homework assignment + */ + async fetchGradeDetails( + term: string, + courseId: string, + gradeableId: string + ): Promise { + try { + const response = await this.client.get( + `/api/${term}/${courseId}/gradeable/${gradeableId}/values` + ); + return response.data; + } catch (error: unknown) { + console.error('Error fetching grade details:', error); + throw new Error(getErrorMessage(error, 'Failed to fetch grade details.')); } - - - /** - * Fetch all courses for the authenticated user - */ - async fetchCourses(token?: string): Promise { - try { - const response = await this.client.get('/api/courses'); - return response.data; - } catch (error: any) { - console.error('Error fetching courses:', error); - throw new Error(error.response?.data?.message || 'Failed to fetch courses.'); - } + } + + /** + * Poll fetchGradeDetails until autograding_complete is true and test_cases has data. + * @param intervalMs Delay between requests (default 2000) + * @param timeoutMs Stop after this many ms (default 300000 = 5 min); 0 = no timeout + * @returns The final AutoGraderDetails with complete data + */ + async pollGradeDetailsUntilComplete( + term: string, + courseId: string, + gradeableId: string, + options?: { + intervalMs?: number; + timeoutMs?: number; + token?: vscode.CancellationToken; } - - async fetchGradables(courseId: string, term: string): Promise { - try { - const url = `/api/${term}/${courseId}/gradeables`; - const response = await this.client.get(url); - return response.data; - } catch (error: any) { - console.error('Error fetching gradables:', error); - throw new Error(error.response?.data?.message || 'Failed to fetch gradables.'); - } + ): Promise { + const intervalMs = options?.intervalMs ?? 2000; + const timeoutMs = options?.timeoutMs ?? 300000; + const token = options?.token; + const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0; + + const isComplete = (res: AutoGraderDetails): boolean => + res?.data?.autograding_complete === true && + Array.isArray(res.data.test_cases) && + res.data.test_cases.length > 0; + + for (;;) { + if (token?.isCancellationRequested) { + throw new Error('Cancelled'); + } + if (deadline > 0 && Date.now() >= deadline) { + throw new Error('Autograding did not complete within the timeout.'); + } + + const result = await this.fetchGradeDetails(term, courseId, gradeableId); + if (isComplete(result)) { + return result; + } + + await new Promise(r => setTimeout(r, intervalMs)); } - - /** - * Fetch grade details for a specific homework assignment - */ - async fetchGradeDetails(term: string, courseId: string, gradeableId: string): Promise { - try { - const response = await this.client.get(`/api/${term}/${courseId}/gradeable/${gradeableId}/values`); - return response.data; - } catch (error: any) { - console.error('Error fetching grade details:', error); - throw new Error(error.response?.data?.message || 'Failed to fetch grade details.'); - } + } + + async submitVCSGradable( + term: string, + courseId: string, + gradeableId: string + ): Promise { + try { + // git_repo_id is literally not used, but is required by the API *ugh* + const url = `/api/${term}/${courseId}/gradeable/${gradeableId}/upload?vcs_upload=true&git_repo_id=true`; + const response = await this.client.post(url); + return response.data; + } catch (error: unknown) { + console.error('Error submitt`ing VCS gradable:', error); + throw new Error(getErrorMessage(error, 'Failed to submit VCS gradable.')); } - - /** - * Poll fetchGradeDetails until autograding_complete is true and test_cases has data. - * @param intervalMs Delay between requests (default 2000) - * @param timeoutMs Stop after this many ms (default 300000 = 5 min); 0 = no timeout - * @returns The final AutoGraderDetails with complete data - */ - async pollGradeDetailsUntilComplete( - term: string, - courseId: string, - gradeableId: string, - options?: { intervalMs?: number; timeoutMs?: number; token?: vscode.CancellationToken } - ): Promise { - const intervalMs = options?.intervalMs ?? 2000; - const timeoutMs = options?.timeoutMs ?? 300000; - const token = options?.token; - const deadline = timeoutMs > 0 ? Date.now() + timeoutMs : 0; - - const isComplete = (res: AutoGraderDetails): boolean => - res?.data?.autograding_complete === true && - Array.isArray(res.data.test_cases) && - res.data.test_cases.length > 0; - - for (;;) { - if (token?.isCancellationRequested) { - throw new Error('Cancelled'); - } - if (deadline > 0 && Date.now() >= deadline) { - throw new Error('Autograding did not complete within the timeout.'); - } - - const result = await this.fetchGradeDetails(term, courseId, gradeableId); - if (isComplete(result)) { - return result; - } - - await new Promise((r) => setTimeout(r, intervalMs)); - } - } - - async submitVCSGradable(term: string, courseId: string, gradeableId: string): Promise { - try { - // git_repo_id is literally not used, but is required by the API *ugh* - const url = `/api/${term}/${courseId}/gradeable/${gradeableId}/upload?vcs_upload=true&git_repo_id=true`; - const response = await this.client.post(url); - return response.data; - } catch (error: any) { - console.error('Error submitting VCS gradable:', error); - throw new Error(error.response?.data?.message || 'Failed to submit VCS gradable.'); - } + } + + /** + * Fetch previous attempts for a specific homework assignment + */ + async fetchPreviousAttempts( + term: string, + courseId: string, + gradeableId: string + ): Promise { + try { + const url = `/api/${term}/${courseId}/gradeable/${gradeableId}/attempts`; + const response = await this.client.get(url); + return response.data; + } catch (error: unknown) { + console.error('Error fetching previous attempts:', error); + throw new Error( + getErrorMessage(error, 'Failed to fetch previous attempts.') + ); } - - - /** - * Fetch previous attempts for a specific homework assignment - */ - async fetchPreviousAttempts(term: string, courseId: string, gradeableId: string): Promise { - try { - const url = `/api/${term}/${courseId}/gradeable/${gradeableId}/attempts`; - const response = await this.client.get(url); - return response.data; - } catch (error: any) { - console.error('Error fetching previous attempts:', error); - throw new Error(error.response?.data?.message || 'Failed to fetch previous attempts.'); - } - } - - static getInstance(context: vscode.ExtensionContext, apiBaseUrl: string): ApiService { - if (!ApiService.instance) { - ApiService.instance = new ApiService(context, apiBaseUrl); - } - return ApiService.instance; + } + + static getInstance( + context: vscode.ExtensionContext, + apiBaseUrl: string + ): ApiService { + if (!ApiService.instance) { + ApiService.instance = new ApiService(context, apiBaseUrl); } -} \ No newline at end of file + return ApiService.instance; + } +} diff --git a/src/services/authService.ts b/src/services/authService.ts index bedc58f..0e03e17 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -3,146 +3,190 @@ import { ApiService } from './apiService'; import * as keytar from 'keytar'; export class AuthService { - // we need to store the token in the global state, but also store it in the - // system keychain - private context: vscode.ExtensionContext; - private apiService: ApiService; - private static instance: AuthService; - constructor(context: vscode.ExtensionContext, apiBaseUrl: string = "") { - this.context = context; - this.apiService = ApiService.getInstance(context, ""); + // we need to store the token in the global state, but also store it in the + // system keychain + private context: vscode.ExtensionContext; + private apiService: ApiService; + private static instance: AuthService; + constructor(context: vscode.ExtensionContext, apiBaseUrl: string = '') { + this.context = context; + this.apiService = ApiService.getInstance(context, ''); + } + + async initialize(): Promise { + console.log('Initializing AuthService'); + + // Get base URL from configuration + const config = vscode.workspace.getConfiguration('submitty'); + let baseUrl = config.get('baseUrl', ''); + + // If base URL is configured, set it on the API service + if (baseUrl) { + this.apiService.setBaseUrl(baseUrl); } - async initialize() { - console.log("Initializing AuthService"); - - // Get base URL from configuration - const config = vscode.workspace.getConfiguration('submitty'); - let baseUrl = config.get('baseUrl', ''); - - // If base URL is configured, set it on the API service - if (baseUrl) { - this.apiService.setBaseUrl(baseUrl); - } - - const token = await this.getToken(); - console.log("Token:", token); - if (token) { - // Token exists, set it on the API service - this.apiService.setAuthorizationToken(token); - console.log("Token set on API service"); - return; - } - - console.log("No token found, prompting for credentials"); - - // If no base URL is configured, prompt for it - if (!baseUrl) { - const inputUrl = await vscode.window.showInputBox({ - prompt: 'Enter Submitty API URL', - placeHolder: 'https://example.submitty.edu', - ignoreFocusOut: true, - validateInput: (value) => { - if (!value || value.trim().length === 0) { - return 'URL is required'; - } - try { - new URL(value); - return null; - } catch { - return 'Please enter a valid URL'; - } - } - }); - - if (!inputUrl) { - // User cancelled - return; + const token = await this.getToken(); + console.log('Token:', token); + if (token) { + // Token exists, set it on the API service + this.apiService.setAuthorizationToken(token); + console.log('Token set on API service'); + + // If baseUrl isn't configured yet, fetch it now so API calls work. + if (!baseUrl) { + const inputUrl = await vscode.window.showInputBox({ + prompt: 'Enter Submitty API URL', + placeHolder: 'https://example.submitty.edu', + ignoreFocusOut: true, + validateInput: value => { + if (!value || value.trim().length === 0) { + return 'URL is required'; } - - baseUrl = inputUrl.trim(); - - // Save base URL to configuration - await config.update('baseUrl', baseUrl, vscode.ConfigurationTarget.Global); - - // Set the base URL on the API service - this.apiService.setBaseUrl(baseUrl); - } - - const userId = await vscode.window.showInputBox({ - prompt: 'Enter your Submitty username', - placeHolder: 'Username', - ignoreFocusOut: true, - validateInput: (value) => { - if (!value || value.trim().length === 0) { - return 'Username is required'; - } - return null; + try { + new URL(value); + return null; + } catch { + return 'Please enter a valid URL'; } + }, }); - if (!userId) { - // User cancelled - return; + if (!inputUrl) { + return; } - const password = await vscode.window.showInputBox({ - prompt: 'Enter your Submitty password', - placeHolder: 'Password', - password: true, - ignoreFocusOut: true, - validateInput: (value) => { - if (!value || value.trim().length === 0) { - return 'Password is required'; - } - return null; - } - }); - - if (!password) { - // User cancelled - return; - } + baseUrl = inputUrl.trim(); - // Update API service with URL and login - try { - // Perform login - await this.login(userId.trim(), password); + await config.update( + 'baseUrl', + baseUrl, + vscode.ConfigurationTarget.Global + ); + this.apiService.setBaseUrl(baseUrl); + } - vscode.window.showInformationMessage('Successfully logged in to Submitty'); - } catch (error: any) { - vscode.window.showErrorMessage(`Login failed: ${error.message}`); - throw error; - } + return; } - // store token - private async storeToken(token: string) { - await keytar.setPassword('submittyToken', 'submittyToken', token); + console.log('No token found, prompting for credentials'); + + // If no base URL is configured, prompt for it + if (!baseUrl) { + const inputUrl = await vscode.window.showInputBox({ + prompt: 'Enter Submitty API URL', + placeHolder: 'https://example.submitty.edu', + ignoreFocusOut: true, + validateInput: value => { + if (!value || value.trim().length === 0) { + return 'URL is required'; + } + try { + new URL(value); + return null; + } catch { + return 'Please enter a valid URL'; + } + }, + }); + + if (!inputUrl) { + // User cancelled + return; + } + + baseUrl = inputUrl.trim(); + + // Save base URL to configuration + await config.update( + 'baseUrl', + baseUrl, + vscode.ConfigurationTarget.Global + ); + + // Set the base URL on the API service + this.apiService.setBaseUrl(baseUrl); } - // get token - private async getToken() { - return await keytar.getPassword('submittyToken', 'submittyToken'); - } + const userId = await vscode.window.showInputBox({ + prompt: 'Enter your Submitty username', + placeHolder: 'Username', + ignoreFocusOut: true, + validateInput: value => { + if (!value || value.trim().length === 0) { + return 'Username is required'; + } + return null; + }, + }); - // public method to get token - async getAuthorizationToken(): Promise { - return await this.getToken(); + if (!userId) { + // User cancelled + return; } - private async login(userId: string, password: string): Promise { - const token = await this.apiService.login(userId, password); - this.apiService.setAuthorizationToken(token); - // store token in system keychain - this.storeToken(token); - return token; + const password = await vscode.window.showInputBox({ + prompt: 'Enter your Submitty password', + placeHolder: 'Password', + password: true, + ignoreFocusOut: true, + validateInput: value => { + if (!value || value.trim().length === 0) { + return 'Password is required'; + } + return null; + }, + }); + + if (!password) { + // User cancelled + return; } - static getInstance(context: vscode.ExtensionContext, apiBaseUrl: string = ""): AuthService { - if (!AuthService.instance) { - AuthService.instance = new AuthService(context); - } - return AuthService.instance; + // Update API service with URL and login + try { + // Perform login + await this.login(userId.trim(), password); + + vscode.window.showInformationMessage( + 'Successfully logged in to Submitty' + ); + } catch (error: unknown) { + const err = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Login failed: ${err}`); + throw error; + } + } + + // store token + private async storeToken(token: string): Promise { + await keytar.setPassword('submittyToken', 'submittyToken', token); + } + + // get token + private async getToken(): Promise { + return await keytar.getPassword('submittyToken', 'submittyToken'); + } + + // public method to get token + async getAuthorizationToken(): Promise { + return await this.getToken(); + } + + private async login(userId: string, password: string): Promise { + const token = await this.apiService.login(userId, password); + this.apiService.setAuthorizationToken(token); + // store token in system keychain + await this.storeToken(token); + return token; + } + + static getInstance( + context: vscode.ExtensionContext, + apiBaseUrl: string = '' + ): AuthService { + if (!AuthService.instance) { + AuthService.instance = new AuthService(context); } + return AuthService.instance; + } } diff --git a/src/services/courseRepoResolver.ts b/src/services/courseRepoResolver.ts new file mode 100644 index 0000000..07bb787 --- /dev/null +++ b/src/services/courseRepoResolver.ts @@ -0,0 +1,170 @@ +import * as vscode from 'vscode'; +import * as fs from 'fs'; +import * as path from 'path'; +import { ApiService } from './apiService'; +import { AuthService } from './authService'; +import { GitService } from './gitService'; +import type { Course } from '../interfaces/Courses'; + +export interface CourseRepoContext { + term: string; + courseId: string; +} + +function normalizeForMatch(input: string): string { + return ( + input + .toLowerCase() + // Keep only alphanumerics so variants like "Fall 2024" vs "fall2024" match. + .replace(/[^a-z0-9]/g, '') + ); +} + +function readTextFileSafe(filePath: string): string | null { + try { + return fs.readFileSync(filePath, 'utf8'); + } catch { + return null; + } +} + +function getGitDirPath(repoRootPath: string): string | null { + const gitEntryPath = path.join(repoRootPath, '.git'); + if (!fs.existsSync(gitEntryPath)) { + return null; + } + + try { + const stat = fs.statSync(gitEntryPath); + if (stat.isDirectory()) { + return gitEntryPath; + } + + if (stat.isFile()) { + // Worktrees/linked clones can have a .git file like: "gitdir: /abs/path/to/.git/worktrees/..." + const gitFileContents = readTextFileSafe(gitEntryPath); + if (!gitFileContents) { + return null; + } + + const match = gitFileContents.match(/^\s*gitdir:\s*(.+)\s*$/m); + if (!match?.[1]) { + return null; + } + + const gitdirRaw = match[1].trim(); + return path.isAbsolute(gitdirRaw) + ? gitdirRaw + : path.resolve(repoRootPath, gitdirRaw); + } + } catch { + return null; + } + + return null; +} + +function extractGitRemoteUrlsFromConfig(gitConfigText: string): string[] { + const urls: string[] = []; + + // Example: + // [remote "origin"] + // url = https://example/.../term/courseId/... + const urlRegex = /^\s*url\s*=\s*(.+)\s*$/gim; + let match: RegExpExecArray | null = null; + + while ((match = urlRegex.exec(gitConfigText))) { + const rawUrl = match[1]?.trim(); + if (rawUrl) { + urls.push(rawUrl); + } + } + + return urls; +} + +export class CourseRepoResolver { + constructor( + private readonly apiService: ApiService, + private readonly authService: AuthService, + private readonly gitService: GitService + ) {} + + async resolveCourseContextFromRepo(): Promise { + const repo = this.gitService.getRepository(); + if (!repo) { + return null; + } + + const repoRootPath = repo.rootUri.fsPath; + const gitDirPath = getGitDirPath(repoRootPath); + if (!gitDirPath) { + return null; + } + + const gitConfigText = readTextFileSafe(path.join(gitDirPath, 'config')); + if (!gitConfigText) { + return null; + } + + const remoteUrls = extractGitRemoteUrlsFromConfig(gitConfigText); + if (remoteUrls.length === 0) { + return null; + } + + const token = await this.authService.getAuthorizationToken(); + if (!token) { + // No auth token -> can't map remotes to courses via API. + return null; + } + + const baseUrl = vscode.workspace + .getConfiguration('submitty') + .get('baseUrl', ''); + if (!baseUrl) { + // Without baseUrl, we can't call the API. + return null; + } + + this.apiService.setBaseUrl(baseUrl); + this.apiService.setAuthorizationToken(token); + + // Fetch courses and match based on whether their (term, courseId) strings appear in remote URLs. + const coursesResponse = await this.apiService.fetchCourses(token); + const courses = coursesResponse.data.unarchived_courses; + + const remoteText = remoteUrls.join(' '); + const remoteNorm = normalizeForMatch(remoteText); + + let best: { course: Course; score: number } | null = null; + + for (const course of courses) { + const courseIdNorm = normalizeForMatch(course.title); + const termNorm = normalizeForMatch(course.semester); + + let score = 0; + + if (remoteNorm.includes(courseIdNorm)) { + score += 6; + } + if (remoteNorm.includes(termNorm)) { + score += 3; + } + if ( + remoteText.toLowerCase().includes(course.display_name.toLowerCase()) + ) { + score += 1; + } + + if (!best || score > best.score) { + best = { course, score }; + } + } + + if (!best || best.score < 6) { + return null; + } + + return { term: best.course.semester, courseId: best.course.title }; + } +} diff --git a/src/services/gitService.ts b/src/services/gitService.ts index ed1db65..c32ef19 100644 --- a/src/services/gitService.ts +++ b/src/services/gitService.ts @@ -1,87 +1,114 @@ import * as vscode from 'vscode'; -import type { GitApi, GitExtension, Repository, CommitOptions, ForcePushMode } from '../typings/vscode-git'; +import type { + GitExtension, + Repository, + CommitOptions, + ForcePushMode, +} from '../typings/vscode-git'; +import { API } from '../typings/vscode-git'; /** * Service that delegates to the built-in vscode.git extension for * push, pull, and commit in the current workspace repository. */ export class GitService { - private gitApi: GitApi | null = null; + private gitApi: API | null = null; - private getApi(): GitApi | null { - if (this.gitApi !== null) { - return this.gitApi; - } - const ext = vscode.extensions.getExtension('vscode.git'); - if (!ext?.isActive) { - return null; - } - try { - this.gitApi = ext.exports.getAPI(1); - return this.gitApi; - } catch { - return null; - } + private getApi(): API | null { + if (this.gitApi !== null) { + return this.gitApi; } + const ext = vscode.extensions.getExtension('vscode.git'); + if (!ext?.isActive) { + return null; + } + try { + this.gitApi = ext.exports.getAPI(1); + return this.gitApi; + } catch { + return null; + } + } - /** - * Get the Git repository for the given URI, or the first workspace folder. - */ - getRepository(uri?: vscode.Uri): Repository | null { - const api = this.getApi(); - if (!api) { - return null; - } - if (uri) { - return api.getRepository(uri) ?? null; - } - const folder = vscode.workspace.workspaceFolders?.[0]; - if (!folder) { - return api.repositories.length > 0 ? api.repositories[0] : null; - } - return api.getRepository(folder.uri) ?? api.repositories[0] ?? null; + /** + * Get the Git repository for the given URI, or the first workspace folder. + */ + getRepository(uri?: vscode.Uri): Repository | null { + const api = this.getApi(); + if (!api) { + return null; + } + if (uri) { + return api.getRepository(uri); + } + const folder = vscode.workspace.workspaceFolders?.[0]; + if (!folder) { + return api.repositories.length > 0 ? api.repositories[0] : null; } + return api.getRepository(folder.uri) ?? api.repositories[0]; + } + + /** + * Commit changes in the repository. Optionally stage all changes first. + */ + async commit(message: string, options?: CommitOptions): Promise { + const repo = this.getRepository(); + if (!repo) { + throw new Error( + 'No Git repository found. Open a workspace folder that is a Git repo.' + ); + } + + // check to see if there are any changes to commit + const status = (await repo.status()) as unknown as { + modified: unknown[]; + untracked: unknown[]; + deleted: unknown[]; + }; - /** - * Commit changes in the repository. Optionally stage all changes first. - */ - async commit(message: string, options?: CommitOptions): Promise { - const repo = this.getRepository(); - if (!repo) { - throw new Error('No Git repository found. Open a workspace folder that is a Git repo.'); - } - await repo.commit(message, options); + if ( + status.modified.length === 0 && + status.untracked.length === 0 && + status.deleted.length === 0 + ) { + throw new Error('No changes to commit.'); } + await repo.commit(message, options); + } - /** - * Pull from the current branch's upstream. - */ - async pull(): Promise { - const repo = this.getRepository(); - if (!repo) { - throw new Error('No Git repository found. Open a workspace folder that is a Git repo.'); - } - await repo.pull(); + /** + * Pull from the current branch's upstream. + */ + async pull(): Promise { + const repo = this.getRepository(); + if (!repo) { + throw new Error( + 'No Git repository found. Open a workspace folder that is a Git repo.' + ); } + await repo.pull(); + } - /** - * Push the current branch. Optionally set upstream or force push. - */ - async push(options?: { - remote?: string; - branch?: string; - setUpstream?: boolean; - force?: ForcePushMode; - }): Promise { - const repo = this.getRepository(); - if (!repo) { - throw new Error('No Git repository found. Open a workspace folder that is a Git repo.'); - } - await repo.push( - options?.remote, - options?.branch, - options?.setUpstream, - options?.force - ); + /** + * Push the current branch. Optionally set upstream or force push. + */ + async push(options?: { + remote?: string; + branch?: string; + setUpstream?: boolean; + force?: ForcePushMode; + }): Promise { + const repo = this.getRepository(); + if (!repo) { + throw new Error( + 'No Git repository found. Open a workspace folder that is a Git repo.' + ); } + await repo.push( + options?.remote, + options?.branch, + options?.setUpstream, + options?.force + ); + } } diff --git a/src/services/testingService.ts b/src/services/testingService.ts index 03dc494..d7b9b5d 100644 --- a/src/services/testingService.ts +++ b/src/services/testingService.ts @@ -1,6 +1,11 @@ import * as vscode from 'vscode'; import { ApiService } from './apiService'; -import type { AutoGraderDetailsData, TestCase } from '../interfaces/AutoGraderDetails'; +import type { + AutoGraderDetails, + AutoGraderDetailsData, + TestCase, + Autocheck, +} from '../interfaces/AutoGraderDetails'; const CONTROLLER_ID = 'submittyAutograder'; const CONTROLLER_LABEL = 'Submitty Autograder'; @@ -9,174 +14,304 @@ const POLL_INTERVAL_MS = 2000; const POLL_TIMEOUT_MS = 300000; // 5 min interface GradeableMeta { - term: string; - courseId: string; - gradeableId: string; + term: string; + courseId: string; + gradeableId: string; } export class TestingService { - private controller: vscode.TestController; - private rootItem: vscode.TestItem; - private gradeableMeta = new WeakMap(); - private testCaseMeta = new WeakMap(); - - constructor( - private readonly context: vscode.ExtensionContext, - private readonly apiService: ApiService - ) { - this.controller = vscode.tests.createTestController(CONTROLLER_ID, CONTROLLER_LABEL); - this.rootItem = this.controller.createTestItem(ROOT_ID, 'Submitty', undefined); - this.rootItem.canResolveChildren = true; - this.controller.items.add(this.rootItem); - - this.controller.resolveHandler = async (item) => this.resolveHandler(item); - const runProfile = this.controller.createRunProfile( - 'Run', - vscode.TestRunProfileKind.Run, - (request, token) => this.runHandler(request, token) - ); - runProfile.isDefault = true; + private controller: vscode.TestController; + private rootItem: vscode.TestItem; + private gradeableMeta = new WeakMap(); + private testCaseMeta = new WeakMap(); + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly apiService: ApiService + ) { + this.controller = vscode.tests.createTestController( + CONTROLLER_ID, + CONTROLLER_LABEL + ); + this.rootItem = this.controller.createTestItem( + ROOT_ID, + 'Submitty', + undefined + ); + this.rootItem.canResolveChildren = true; + this.controller.items.add(this.rootItem); + + this.controller.resolveHandler = async item => this.resolveHandler(item); + const runProfile = this.controller.createRunProfile( + 'Run', + vscode.TestRunProfileKind.Run, + (request, token) => this.runHandler(request, token) + ); + runProfile.isDefault = true; + + context.subscriptions.push(this.controller); + } - context.subscriptions.push(this.controller); + /** + * Add a gradeable to the Test Explorer so the user can run it and see results. + * Call this when the user triggers "Grade" or "Run autograder" for a gradeable. + */ + addGradeable( + term: string, + courseId: string, + gradeableId: string, + label: string + ): vscode.TestItem { + const id = `${term}/${courseId}/${gradeableId}`; + let item = this.rootItem.children.get(id); + if (!item) { + item = this.controller.createTestItem(id, label, undefined); + item.canResolveChildren = true; + this.gradeableMeta.set(item, { term, courseId, gradeableId }); + this.rootItem.children.add(item); } + return item; + } - /** - * Add a gradeable to the Test Explorer so the user can run it and see results. - * Call this when the user triggers "Grade" or "Run autograder" for a gradeable. - */ - addGradeable(term: string, courseId: string, gradeableId: string, label: string): vscode.TestItem { - const id = `${term}/${courseId}/${gradeableId}`; - let item = this.rootItem.children.get(id); - if (!item) { - item = this.controller.createTestItem(id, label, undefined); - item.canResolveChildren = true; - this.gradeableMeta.set(item, { term, courseId, gradeableId }); - this.rootItem.children.add(item); - } - return item; + /** + * Run a single gradeable in the Test Explorer using an already-fetched autograder result. + * Used when the user clicks "Grade" in the sidebar: submit → poll → then report here. + */ + runGradeableWithResult( + term: string, + courseId: string, + gradeableId: string, + label: string, + result: AutoGraderDetails + ): void { + const item = this.addGradeable(term, courseId, gradeableId, label); + this.syncTestCaseChildren(item, result.data); + + const run = this.controller.createTestRun( + new vscode.TestRunRequest([item]) + ); + run.started(item); + run.appendOutput(`Autograder completed for ${item.label}.\r\n`); + this.reportGradeableResult(run, item, result.data); + run.end(); + } + + private getGradeableMeta(item: vscode.TestItem): GradeableMeta | undefined { + return this.gradeableMeta.get(item); + } + + /** + * Convert HTML from autograder actual/expected into plain text for the Test Explorer diff. + * Strips tags and decodes common entities so the diff view is readable. + */ + private stripHtml(html: string): string { + if (!html || typeof html !== 'string') { + return ''; + } + const text = html + .replace(//gi, '\n') + .replace(/<\/div>/gi, '\n') + .replace(/<\/p>/gi, '\n') + .replace(/<[^>]+>/g, '') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/&/g, '&') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/ /g, ' '); + return text.replace(/\n{3,}/g, '\n\n').trim(); + } + + private formatAutocheckOutput( + autochecks: Autocheck[] | undefined, + getValue: (ac: Autocheck) => string + ): string { + if (!autochecks?.length) { + return ''; + } + const parts = autochecks.map(ac => { + const value = this.stripHtml(getValue(ac)); + if (!value) { + return ''; + } + return `[${ac.description}]\n${value}`; + }); + return parts.filter(Boolean).join('\n\n'); + } + + /** + * Format the messages array from all autochecks (e.g. "ERROR: ..." with type failure/warning). + */ + private formatAutocheckMessages(autochecks: Autocheck[] | undefined): string { + if (!autochecks?.length) { + return ''; + } + const parts = autochecks.map(ac => { + const msgLines = (ac.messages ?? []).map( + m => ` • ${m.message}${m.type ? ` (${m.type})` : ''}` + ); + if (msgLines.length === 0) { + return ''; + } + return `[${ac.description}]\n${msgLines.join('\n')}`; + }); + return parts.filter(Boolean).join('\n\n'); + } + + private async resolveHandler( + item: vscode.TestItem | undefined + ): Promise { + if (!item) { + return; } + const meta = this.getGradeableMeta(item); + if (!meta) { + return; + } + // Resolve: poll until complete and populate children (test cases) + try { + const result = await this.apiService.pollGradeDetailsUntilComplete( + meta.term, + meta.courseId, + meta.gradeableId, + { intervalMs: POLL_INTERVAL_MS, timeoutMs: POLL_TIMEOUT_MS } + ); + this.syncTestCaseChildren(item, result.data); + } catch (e) { + console.error('Submitty testing resolve failed:', e); + } + } - private getGradeableMeta(item: vscode.TestItem): GradeableMeta | undefined { - return this.gradeableMeta.get(item); + private syncTestCaseChildren( + gradeableItem: vscode.TestItem, + data: AutoGraderDetailsData + ): void { + const cases = data.test_cases ?? []; + for (let i = 0; i < cases.length; i++) { + const tc = cases[i]; + const id = `tc-${i}-${tc.name ?? i}`; + let child = gradeableItem.children.get(id); + if (!child) { + child = this.controller.createTestItem( + id, + tc.name || `Test ${i + 1}`, + undefined + ); + this.testCaseMeta.set(child, tc); + gradeableItem.children.add(child); + } else { + this.testCaseMeta.set(child, tc); + } } + } - private async resolveHandler(item: vscode.TestItem | undefined): Promise { - if (!item) { - return; + private reportGradeableResult( + run: vscode.TestRun, + item: vscode.TestItem, + _data: AutoGraderDetailsData + ): void { + const start = Date.now(); + let allPassed = true; + item.children.forEach(child => { + const tc = this.testCaseMeta.get(child); + run.started(child); + if (tc) { + const passed = tc.points_received >= (tc.points_available ?? 0); + if (!passed) { + allPassed = false; } - const meta = this.getGradeableMeta(item); - if (!meta) { - return; + const duration = Date.now() - start; + const messageParts = [tc.testcase_message, tc.details].filter(Boolean); + const formattedMessages = this.formatAutocheckMessages(tc.autochecks); + if (formattedMessages) { + messageParts.push('--- Messages ---', formattedMessages); } - // Resolve: poll until complete and populate children (test cases) - try { - const result = await this.apiService.pollGradeDetailsUntilComplete( - meta.term, - meta.courseId, - meta.gradeableId, - { intervalMs: POLL_INTERVAL_MS, timeoutMs: POLL_TIMEOUT_MS } - ); - this.syncTestCaseChildren(item, result.data); - } catch (e) { - console.error('Submitty testing resolve failed:', e); + const messageText = messageParts.join('\n') || 'Failed'; + if (passed) { + run.passed(child, duration); + } else { + const msg = new vscode.TestMessage(messageText); + msg.expectedOutput = this.formatAutocheckOutput( + tc.autochecks, + ac => ac.expected + ); + msg.actualOutput = this.formatAutocheckOutput( + tc.autochecks, + ac => ac.actual + ); + run.failed(child, msg, duration); } - } + } else { + run.passed(child, 0); + } + }); - private syncTestCaseChildren(gradeableItem: vscode.TestItem, data: AutoGraderDetailsData): void { - const cases = data.test_cases ?? []; - for (let i = 0; i < cases.length; i++) { - const tc = cases[i]; - const id = `tc-${i}-${tc.name ?? i}`; - let child = gradeableItem.children.get(id); - if (!child) { - child = this.controller.createTestItem(id, tc.name || `Test ${i + 1}`, undefined); - this.testCaseMeta.set(child, tc); - gradeableItem.children.add(child); - } else { - this.testCaseMeta.set(child, tc); - } - } + if (item.children.size === 0) { + run.appendOutput(`No test cases in response.\r\n`); + run.failed(item, new vscode.TestMessage('No test cases returned.'), 0); + } else { + if (allPassed) { + run.passed(item, Date.now() - start); + } else { + run.failed( + item, + new vscode.TestMessage('Some test cases failed.'), + Date.now() - start + ); + } } + } - private async runHandler(request: vscode.TestRunRequest, token: vscode.CancellationToken): Promise { - const run = this.controller.createTestRun(request); - const queue: vscode.TestItem[] = []; - - if (request.include) { - request.include.forEach((t) => { - if (t.id === ROOT_ID) { - this.rootItem.children.forEach((c) => queue.push(c)); - } else { - queue.push(t); - } - }); + private async runHandler( + request: vscode.TestRunRequest, + token: vscode.CancellationToken + ): Promise { + const run = this.controller.createTestRun(request); + const queue: vscode.TestItem[] = []; + + if (request.include) { + request.include.forEach(t => { + if (t.id === ROOT_ID) { + this.rootItem.children.forEach(c => queue.push(c)); } else { - this.rootItem.children.forEach((t) => queue.push(t)); + queue.push(t); } + }); + } else { + this.rootItem.children.forEach(t => queue.push(t)); + } - while (queue.length > 0 && !token.isCancellationRequested) { - const item = queue.shift()!; - if (request.exclude?.includes(item)) { - continue; - } - - const meta = this.getGradeableMeta(item); - if (!meta) { - continue; - } - - run.started(item); - run.appendOutput(`Polling grade details for ${item.label}...\r\n`); - - try { - const result = await this.apiService.pollGradeDetailsUntilComplete( - meta.term, - meta.courseId, - meta.gradeableId, - { intervalMs: POLL_INTERVAL_MS, timeoutMs: POLL_TIMEOUT_MS, token } - ); - const data = result.data; - this.syncTestCaseChildren(item, data); - - let allPassed = true; - const start = Date.now(); - item.children.forEach((child) => { - const tc = this.testCaseMeta.get(child); - run.started(child); - if (tc) { - const passed = tc.points_received >= (tc.points_available ?? 0); - if (!passed) { - allPassed = false; - } - const duration = Date.now() - start; - const message = [tc.testcase_message, tc.details].filter(Boolean).join('\n') || undefined; - if (passed) { - run.passed(child, duration); - } else { - run.failed(child, new vscode.TestMessage(message || 'Failed'), duration); - } - } else { - run.passed(child, 0); - } - }); - - if (item.children.size === 0) { - run.appendOutput(`No test cases in response.\r\n`); - run.failed(item, new vscode.TestMessage('No test cases returned.'), 0); - } else { - if (allPassed) { - run.passed(item, Date.now() - start); - } else { - run.failed(item, new vscode.TestMessage('Some test cases failed.'), Date.now() - start); - } - } - } catch (e) { - const err = e instanceof Error ? e.message : String(e); - run.appendOutput(`Error: ${err}\r\n`); - run.failed(item, new vscode.TestMessage(err), 0); - } - } + while (queue.length > 0 && !token.isCancellationRequested) { + const item = queue.shift()!; + if (request.exclude?.includes(item)) { + continue; + } + + const meta = this.getGradeableMeta(item); + if (!meta) { + continue; + } - run.end(); + run.started(item); + run.appendOutput(`Polling grade details for ${item.label}...\r\n`); + + try { + const result = await this.apiService.pollGradeDetailsUntilComplete( + meta.term, + meta.courseId, + meta.gradeableId, + { intervalMs: POLL_INTERVAL_MS, timeoutMs: POLL_TIMEOUT_MS, token } + ); + const data = result.data; + this.syncTestCaseChildren(item, data); + this.reportGradeableResult(run, item, data); + } catch (e) { + const err = e instanceof Error ? e.message : String(e); + run.appendOutput(`Error: ${err}\r\n`); + run.failed(item, new vscode.TestMessage(err), 0); + } } + + run.end(); + } } diff --git a/src/sidebar/classes.html b/src/sidebar/classes.html index 553b5e4..23bb2c0 100644 --- a/src/sidebar/classes.html +++ b/src/sidebar/classes.html @@ -143,9 +143,11 @@

Courses

e.stopPropagation(); vscode.postMessage({ command: 'grade', - term: this.dataset.term, - courseId: this.dataset.courseId, - gradeableId: this.dataset.gradeableId, + data: { + term: this.dataset.term, + courseId: this.dataset.courseId, + gradeableId: this.dataset.gradeableId, + }, }); }); }); diff --git a/src/sidebar/login.html b/src/sidebar/login.html index 6308db9..b466d15 100644 --- a/src/sidebar/login.html +++ b/src/sidebar/login.html @@ -1,84 +1,84 @@ - + - - + + - - + +

Submitty Login

- - + +
- - + +
- - + +
- - \ No newline at end of file + + diff --git a/src/sidebarContent.ts b/src/sidebarContent.ts index ff9de23..a16a84c 100644 --- a/src/sidebarContent.ts +++ b/src/sidebarContent.ts @@ -3,11 +3,21 @@ import * as path from 'path'; import * as fs from 'fs'; export function getLoginHtml(context: vscode.ExtensionContext): string { - const filePath = path.join(context.extensionPath, 'src', 'sidebar', 'login.html'); - return fs.readFileSync(filePath, 'utf8'); + const filePath = path.join( + context.extensionPath, + 'src', + 'sidebar', + 'login.html' + ); + return fs.readFileSync(filePath, 'utf8'); } export function getClassesHtml(context: vscode.ExtensionContext): string { - const filePath = path.join(context.extensionPath, 'src', 'sidebar', 'classes.html'); - return fs.readFileSync(filePath, 'utf8'); -} \ No newline at end of file + const filePath = path.join( + context.extensionPath, + 'src', + 'sidebar', + 'classes.html' + ); + return fs.readFileSync(filePath, 'utf8'); +} diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index a0c2861..935a8f7 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -2,169 +2,319 @@ import * as vscode from 'vscode'; import { getClassesHtml } from './sidebarContent'; import { ApiService } from './services/apiService'; import { AuthService } from './services/authService'; -import type { TestingService } from './services/testingService'; +import { GitService } from './services/gitService'; +import { Gradable } from './interfaces/Gradables'; +import { TestingService } from './services/testingService'; +import { MessageCommand } from './typings/message'; export class SidebarProvider implements vscode.WebviewViewProvider { - private _view?: vscode.WebviewView; - private apiService: ApiService; - private authService: AuthService; - private isInitialized: boolean = false; - - constructor( - private readonly context: vscode.ExtensionContext, - private readonly testingService?: TestingService - ) { - this.apiService = ApiService.getInstance(this.context, ""); - this.authService = AuthService.getInstance(this.context); + private _view?: vscode.WebviewView; + private apiService: ApiService; + private authService: AuthService; + private isInitialized: boolean = false; + private visibilityDisposable?: vscode.Disposable; + private isLoadingCourses: boolean = false; + + constructor( + private readonly context: vscode.ExtensionContext, + private readonly testingService?: TestingService, + private readonly gitService?: GitService + ) { + this.apiService = ApiService.getInstance(this.context, ''); + this.authService = AuthService.getInstance(this.context); + } + + async resolveWebviewView( + webviewView: vscode.WebviewView, + _context: vscode.WebviewViewResolveContext, + _token: vscode.CancellationToken + ): Promise { + this._view = webviewView; + + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.context.extensionUri, 'src', 'webview'), + ], + }; + + // Initially show blank screen + webviewView.webview.html = this.getBlankHtml(); + + // Reload courses any time the view becomes visible again (e.g. user + // closes/hides the panel and comes back). + this.visibilityDisposable?.dispose(); + this.visibilityDisposable = webviewView.onDidChangeVisibility(async () => { + if (webviewView.visible) { + await this.loadCourses(); + } + }); + + // Initialize authentication when sidebar is opened (only once) + if (!this.isInitialized) { + this.isInitialized = true; + try { + await this.authService.initialize(); + + // After authentication, fetch and display courses + await this.loadCourses(); + } catch (error: any) { + console.error('Authentication initialization failed:', error); + // Error is already shown to user in authService + } + } else { + // If already initialized, just load courses + await this.loadCourses(); } - async resolveWebviewView( - webviewView: vscode.WebviewView, - _context: vscode.WebviewViewResolveContext, - _token: vscode.CancellationToken - ) { - this._view = webviewView; - - webviewView.webview.options = { - enableScripts: true, - localResourceRoots: [vscode.Uri.joinPath(this.context.extensionUri, 'src', 'webview')], - }; - - // Initially show blank screen - webviewView.webview.html = this.getBlankHtml(); - - // Initialize authentication when sidebar is opened (only once) - if (!this.isInitialized) { - this.isInitialized = true; - try { - await this.authService.initialize(); - - // After authentication, fetch and display courses - await this.loadCourses(); - } catch (error: any) { - console.error('Authentication initialization failed:', error); - // Error is already shown to user in authService - } - } else { - // If already initialized, just load courses - await this.loadCourses(); - } + // Handle messages from the webview + webviewView.webview.onDidReceiveMessage( + async message => { + await this.handleMessage(message, webviewView); + }, + undefined, + this.context.subscriptions + ); + } - // Handle messages from the webview - webviewView.webview.onDidReceiveMessage( - async (message) => { - await this.handleMessage(message, webviewView); - }, - undefined, - this.context.subscriptions - ); + private async loadCourses(): Promise { + if (!this._view) { + return; } - private async loadCourses(): Promise { - if (!this._view) { - return; - } + if (this.isLoadingCourses) { + return; + } + + this.isLoadingCourses = true; + try { + const token = await this.authService.getAuthorizationToken(); + if (!token) { + return; + } + + // Show classes HTML + this._view.webview.html = getClassesHtml(this.context); + + // Fetch and display courses + await this.fetchAndDisplayCourses(token, this._view); + } catch (error: unknown) { + const err = error instanceof Error ? error.message : String(error); + console.error('Failed to load courses:', error); + vscode.window.showErrorMessage(`Failed to load courses: ${err}`); + } finally { + this.isLoadingCourses = false; + } + } + private async handleMessage( + message: unknown, + view: vscode.WebviewView + ): Promise { + console.log('handleMessage', message); + if (!message || typeof message !== 'object') { + return; + } + const msg = message as { command?: unknown; data?: unknown }; + if (typeof msg.command !== 'string') { + return; + } + + switch (msg.command) { + case MessageCommand.FETCH_AND_DISPLAY_COURSES: try { - const token = await this.authService.getAuthorizationToken(); - if (!token) { - return; - } - - // Show classes HTML - this._view.webview.html = getClassesHtml(this.context); - - // Fetch and display courses - await this.fetchAndDisplayCourses(token, this._view); - } catch (error: any) { - console.error('Failed to load courses:', error); - vscode.window.showErrorMessage(`Failed to load courses: ${error.message}`); + const token = await this.authService.getAuthorizationToken(); + if (token) { + await this.fetchAndDisplayCourses(token, view); + } + } catch (error: unknown) { + const err = error instanceof Error ? error.message : String(error); + console.error('Failed to fetch and display courses:', error); + view.webview.postMessage({ + command: MessageCommand.ERROR, + data: { message: `Failed to fetch and display courses: ${err}` }, + }); } - } + break; + case MessageCommand.GRADE: + try { + const data = msg.data; + if (!data || typeof data !== 'object') { + throw new Error('Missing grade payload.'); + } + const dataObj = data as Record; + const term = typeof dataObj.term === 'string' ? dataObj.term : null; + const courseId = + typeof dataObj.courseId === 'string' ? dataObj.courseId : null; + const gradeableId = + typeof dataObj.gradeableId === 'string' + ? dataObj.gradeableId + : null; - private async handleMessage(message: any, view: vscode.WebviewView) { - switch (message.command) { - case 'fetchAndDisplayCourses': - const token = await this.authService.getAuthorizationToken(); - if (token) { - await this.fetchAndDisplayCourses(token, view); - } - break; - case 'grade': - await this.handleGrade(message.term, message.courseId, message.gradeableId, view); - break; - default: - vscode.window.showWarningMessage(`Unknown command: ${message.command}`); - break; + if (!term || !courseId || !gradeableId) { + throw new Error('Invalid grade payload.'); + } + console.log('handleGrade', term, courseId, gradeableId); + await this.handleGrade(term, courseId, gradeableId, view); + } catch (error: unknown) { + const err = error instanceof Error ? error.message : String(error); + console.error('Failed to grade:', error); + view.webview.postMessage({ + command: MessageCommand.ERROR, + data: { message: `Failed to grade: ${err}` }, + }); } + break; + default: + vscode.window.showWarningMessage(`Unknown command: ${msg.command}`); + view.webview.postMessage({ + command: MessageCommand.ERROR, + data: { message: `Unknown command: ${msg.command}` }, + }); + break; } + } + private async fetchAndDisplayCourses( + token: string, + view: vscode.WebviewView + ): Promise { + try { + const courses = await this.apiService.fetchCourses(token); + const unarchived = courses.data.unarchived_courses; - private async fetchAndDisplayCourses(token: string, view: vscode.WebviewView): Promise { - try { - const courses = await this.apiService.fetchCourses(token); - const unarchived = courses.data.unarchived_courses; - - const coursesWithGradables = await Promise.all( - unarchived.map(async (course) => { - let gradables: { id: string; title: string }[] = []; - try { - const gradableResponse = await this.apiService.fetchGradables(course.title, course.semester); - gradables = (gradableResponse.data || []).map((g) => ({ id: g.id, title: g.title || g.id })); - } catch (e) { - console.warn(`Failed to fetch gradables for ${course.title}:`, e); - } - return { - semester: course.semester, - title: course.title, - display_name: course.display_name || course.title, - gradables, - }; - }) + const coursesWithGradables = await Promise.all( + unarchived.map(async course => { + let gradables: { id: string; title: string }[] = []; + try { + const gradableResponse = await this.apiService.fetchGradables( + course.title, + course.semester ); + gradables = Object.values(gradableResponse.data || {}).map( + (g: Gradable) => ({ id: g.id, title: g.title || g.id }) + ); + } catch (e) { + console.warn(`Failed to fetch gradables for ${course.title}:`, e); + } + return { + semester: course.semester, + title: course.title, + display_name: course.display_name || course.title, + gradables, + }; + }) + ); - view.webview.postMessage({ - command: 'displayCourses', - data: { courses: coursesWithGradables }, - }); - } catch (error: any) { - vscode.window.showErrorMessage(`Failed to fetch courses: ${error.message}`); - view.webview.postMessage({ command: 'error', message: `Failed to fetch courses: ${error.message}` }); - } + view.webview.postMessage({ + command: MessageCommand.DISPLAY_COURSES, + data: { courses: coursesWithGradables }, + }); + } catch (error: unknown) { + const err = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Failed to fetch courses: ${err}`); + view.webview.postMessage({ + command: MessageCommand.ERROR, + data: { message: `Failed to fetch courses: ${err}` }, + }); } + } - private async handleGrade(term: string, courseId: string, gradeableId: string, view: vscode.WebviewView): Promise { - try { - this.testingService?.addGradeable(term, courseId, gradeableId, gradeableId); - const gradeDetails = await this.apiService.fetchGradeDetails(term, courseId, gradeableId); - const previousAttempts = await this.apiService.fetchPreviousAttempts(term, courseId, gradeableId); // Fetch previous attempts + private async handleGrade( + term: string, + courseId: string, + gradeableId: string, + view: vscode.WebviewView + ): Promise { + try { + this.testingService?.addGradeable( + term, + courseId, + gradeableId, + gradeableId + ); + if (this.gitService) { + view.webview.postMessage({ + command: MessageCommand.GRADE_STARTED, + data: { message: 'Staging and committing...' }, + }); + const commitMessage = new Date().toLocaleString(undefined, { + dateStyle: 'short', + timeStyle: 'medium', + }); + try { + await this.gitService.commit(commitMessage, { all: true }); + view.webview.postMessage({ + command: MessageCommand.GRADE_STARTED, + data: { message: 'Pushing...' }, + }); + await this.gitService.push(); + } catch (error: unknown) { + const err = error instanceof Error ? error.message : String(error); + if (err === 'No changes to commit.') { view.webview.postMessage({ - command: 'displayGrade', - data: { - term, - courseId, - gradeableId, - gradeDetails, - previousAttempts, // Include previous attempts - } + command: MessageCommand.GRADE_STARTED, + data: { message: 'No changes to commit. Skipping git push.' }, }); - - // Send message to PanelProvider - vscode.commands.executeCommand('extension.showGradePanel', { - term, - courseId, - gradeableId, - gradeDetails, - previousAttempts, // Include previous attempts - }); - } catch (error: any) { - vscode.window.showErrorMessage(`Failed to fetch grade details: ${error.message}`); - view.webview.postMessage({ command: 'error', message: `Failed to fetch grade details: ${error.message}` }); + } else { + throw error; + } } + } + + view.webview.postMessage({ + command: MessageCommand.GRADE_STARTED, + data: { message: 'Submitting for grading...' }, + }); + await this.apiService.submitVCSGradable(term, courseId, gradeableId); + + view.webview.postMessage({ + command: MessageCommand.GRADE_STARTED, + data: { message: 'Grading in progress. Polling for results...' }, + }); + const gradeDetails = await this.apiService.pollGradeDetailsUntilComplete( + term, + courseId, + gradeableId + ); + const previousAttempts = await this.apiService.fetchPreviousAttempts( + term, + courseId, + gradeableId + ); + + view.webview.postMessage({ + command: MessageCommand.GRADE_COMPLETED, + data: { + term, + courseId, + gradeableId, + gradeDetails, + previousAttempts, + }, + }); + + if (this.testingService) { + this.testingService.runGradeableWithResult( + term, + courseId, + gradeableId, + gradeableId, + gradeDetails + ); + } + } catch (error: unknown) { + const err = error instanceof Error ? error.message : String(error); + vscode.window.showErrorMessage(`Failed to grade: ${err}`); + view.webview.postMessage({ + command: MessageCommand.ERROR, + data: { message: `Failed to grade: ${err}` }, + }); } + } - private getBlankHtml(): string { - return ` + private getBlankHtml(): string { + return ` @@ -183,6 +333,5 @@ export class SidebarProvider implements vscode.WebviewViewProvider { `; - } + } } - diff --git a/src/test/extension.test.ts b/src/test/extension.test.ts index 4ca0ab4..17e2eab 100644 --- a/src/test/extension.test.ts +++ b/src/test/extension.test.ts @@ -6,10 +6,10 @@ import * as vscode from 'vscode'; // import * as myExtension from '../../extension'; suite('Extension Test Suite', () => { - vscode.window.showInformationMessage('Start all tests.'); + vscode.window.showInformationMessage('Start all tests.'); - test('Sample test', () => { - assert.strictEqual(-1, [1, 2, 3].indexOf(5)); - assert.strictEqual(-1, [1, 2, 3].indexOf(0)); - }); + test('Sample test', () => { + assert.strictEqual(-1, [1, 2, 3].indexOf(5)); + assert.strictEqual(-1, [1, 2, 3].indexOf(0)); + }); }); diff --git a/src/typings/message.ts b/src/typings/message.ts new file mode 100644 index 0000000..74cdd08 --- /dev/null +++ b/src/typings/message.ts @@ -0,0 +1,18 @@ +export const MessageCommand = { + FETCH_AND_DISPLAY_COURSES: 'fetchAndDisplayCourses', + DISPLAY_COURSES: 'displayCourses', + GRADE: 'grade', + GRADE_STARTED: 'gradeStarted', + GRADE_COMPLETED: 'gradeCompleted', + GRADE_ERROR: 'gradeError', + GRADE_CANCELLED: 'gradeCancelled', + GRADE_PAUSED: 'gradePaused', + GRASE_RESUMED: 'gradeResumed', + GRADE_ABORTED: 'gradeAborted', + ERROR: 'error', +} as const; + +export type WebViewMessage = { + command: (typeof MessageCommand)[keyof typeof MessageCommand]; + [key: string]: string | number | boolean | object | null | undefined; +}; diff --git a/src/typings/vscode-git.d.ts b/src/typings/vscode-git.d.ts index 898cff1..d467672 100644 --- a/src/typings/vscode-git.d.ts +++ b/src/typings/vscode-git.d.ts @@ -1,8 +1,18 @@ -/** - * Minimal typings for the built-in Git extension API (vscode.git). - * Used for push, pull, and commit in GitService. - */ -import type { Uri } from 'vscode'; +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { Uri, Event, Disposable, ProviderResult, Command, CancellationToken } from 'vscode'; +export { ProviderResult } from 'vscode'; + +export interface Git { + readonly path: string; +} + +export interface InputBox { + value: string; +} export const enum ForcePushMode { Force, @@ -10,33 +20,493 @@ export const enum ForcePushMode { ForceWithLeaseIfIncludes, } +export const enum RefType { + Head, + RemoteHead, + Tag +} + +export interface Ref { + readonly type: RefType; + readonly name?: string; + readonly commit?: string; + readonly commitDetails?: Commit; + readonly remote?: string; +} + +export interface UpstreamRef { + readonly remote: string; + readonly name: string; + readonly commit?: string; +} + +export interface Branch extends Ref { + readonly upstream?: UpstreamRef; + readonly ahead?: number; + readonly behind?: number; +} + +export interface CommitShortStat { + readonly files: number; + readonly insertions: number; + readonly deletions: number; +} + +export interface Commit { + readonly hash: string; + readonly message: string; + readonly parents: string[]; + readonly authorDate?: Date; + readonly authorName?: string; + readonly authorEmail?: string; + readonly commitDate?: Date; + readonly shortStat?: CommitShortStat; +} + +export interface Submodule { + readonly name: string; + readonly path: string; + readonly url: string; +} + +export interface Remote { + readonly name: string; + readonly fetchUrl?: string; + readonly pushUrl?: string; + readonly isReadOnly: boolean; +} + +export interface Worktree { + readonly name: string; + readonly path: string; + readonly ref: string; + readonly main: boolean; + readonly detached: boolean; +} + +export const enum Status { + INDEX_MODIFIED, + INDEX_ADDED, + INDEX_DELETED, + INDEX_RENAMED, + INDEX_COPIED, + + MODIFIED, + DELETED, + UNTRACKED, + IGNORED, + INTENT_TO_ADD, + INTENT_TO_RENAME, + TYPE_CHANGED, + + ADDED_BY_US, + ADDED_BY_THEM, + DELETED_BY_US, + DELETED_BY_THEM, + BOTH_ADDED, + BOTH_DELETED, + BOTH_MODIFIED +} + +export interface Change { + + /** + * Returns either `originalUri` or `renameUri`, depending + * on whether this change is a rename change. When + * in doubt always use `uri` over the other two alternatives. + */ + readonly uri: Uri; + readonly originalUri: Uri; + readonly renameUri: Uri | undefined; + readonly status: Status; +} + +export interface DiffChange extends Change { + readonly insertions: number; + readonly deletions: number; +} + +export type RepositoryKind = 'repository' | 'submodule' | 'worktree'; + +export interface RepositoryState { + readonly HEAD: Branch | undefined; + readonly refs: Ref[]; + readonly remotes: Remote[]; + readonly submodules: Submodule[]; + readonly worktrees: Worktree[]; + readonly rebaseCommit: Commit | undefined; + + readonly mergeChanges: Change[]; + readonly indexChanges: Change[]; + readonly workingTreeChanges: Change[]; + readonly untrackedChanges: Change[]; + + readonly onDidChange: Event; +} + +export interface RepositoryUIState { + readonly selected: boolean; + readonly onDidChange: Event; +} + +export interface RepositoryAccessDetails { + readonly rootUri: Uri; + readonly lastAccessTime: number; +} + +/** + * Log options. + */ +export interface LogOptions { + /** Max number of log entries to retrieve. If not specified, the default is 32. */ + readonly maxEntries?: number; + readonly path?: string; + /** A commit range, such as "0a47c67f0fb52dd11562af48658bc1dff1d75a38..0bb4bdea78e1db44d728fd6894720071e303304f" */ + readonly range?: string; + readonly reverse?: boolean; + readonly sortByAuthorDate?: boolean; + readonly shortStats?: boolean; + readonly author?: string; + readonly grep?: string; + readonly refNames?: string[]; + readonly maxParents?: number; + readonly skip?: number; +} + export interface CommitOptions { all?: boolean | 'tracked'; amend?: boolean; signoff?: boolean; + /** + * true - sign the commit + * false - do not sign the commit + * undefined - use the repository/global git config + */ signCommit?: boolean; empty?: boolean; noVerify?: boolean; + requireUserConfig?: boolean; + useEditor?: boolean; + verbose?: boolean; + /** + * string - execute the specified command after the commit operation + * undefined - execute the command specified in git.postCommitCommand + * after the commit operation + * null - do not execute any command after the commit operation + */ + postCommitCommand?: string | null; +} + +export interface FetchOptions { + remote?: string; + ref?: string; + all?: boolean; + prune?: boolean; + depth?: number; +} + +export interface InitOptions { + defaultBranch?: string; +} + +export interface CloneOptions { + parentPath?: Uri; + /** + * ref is only used if the repository cache is missed. + */ + ref?: string; + recursive?: boolean; + /** + * If no postCloneAction is provided, then the users setting for git.openAfterClone is used. + */ + postCloneAction?: 'none'; +} + +export interface RefQuery { + readonly contains?: string; + readonly count?: number; + readonly pattern?: string | string[]; + readonly sort?: 'alphabetically' | 'committerdate' | 'creatordate'; +} + +export interface BranchQuery extends RefQuery { + readonly remote?: boolean; } export interface Repository { + readonly rootUri: Uri; - commit(message: string, opts?: CommitOptions): Promise; + readonly inputBox: InputBox; + readonly state: RepositoryState; + readonly ui: RepositoryUIState; + readonly kind: RepositoryKind; + + readonly onDidCommit: Event; + readonly onDidCheckout: Event; + + getConfigs(): Promise<{ key: string; value: string; }[]>; + getConfig(key: string): Promise; + setConfig(key: string, value: string): Promise; + unsetConfig(key: string): Promise; + getGlobalConfig(key: string): Promise; + + getObjectDetails(treeish: string, path: string): Promise<{ mode: string, object: string, size: number }>; + detectObjectType(object: string): Promise<{ mimetype: string, encoding?: string }>; + buffer(ref: string, path: string): Promise; + show(ref: string, path: string): Promise; + getCommit(ref: string): Promise; + + add(paths: string[]): Promise; + revert(paths: string[]): Promise; + clean(paths: string[]): Promise; + + apply(patch: string, reverse?: boolean): Promise; + apply(patch: string, options?: { allowEmpty?: boolean; reverse?: boolean; threeWay?: boolean; }): Promise; + diff(cached?: boolean): Promise; + diffWithHEAD(): Promise; + diffWithHEAD(path: string): Promise; + diffWithHEADShortStats(path?: string): Promise; + diffWith(ref: string): Promise; + diffWith(ref: string, path: string): Promise; + diffIndexWithHEAD(): Promise; + diffIndexWithHEAD(path: string): Promise; + diffIndexWithHEADShortStats(path?: string): Promise; + diffIndexWith(ref: string): Promise; + diffIndexWith(ref: string, path: string): Promise; + diffBlobs(object1: string, object2: string): Promise; + diffBetween(ref1: string, ref2: string): Promise; + diffBetween(ref1: string, ref2: string, path: string): Promise; + diffBetweenPatch(ref1: string, ref2: string, path?: string): Promise; + diffBetweenWithStats(ref1: string, ref2: string, path?: string): Promise; + diffBetweenWithStats2(ref: string, path?: string): Promise; + + hashObject(data: string): Promise; + + createBranch(name: string, checkout: boolean, ref?: string): Promise; + deleteBranch(name: string, force?: boolean): Promise; + getBranch(name: string): Promise; + getBranches(query: BranchQuery, cancellationToken?: CancellationToken): Promise; + getBranchBase(name: string): Promise; + setBranchUpstream(name: string, upstream: string): Promise; + + checkIgnore(paths: string[]): Promise>; + + getRefs(query: RefQuery, cancellationToken?: CancellationToken): Promise; + + getMergeBase(ref1: string, ref2: string): Promise; + + tag(name: string, message: string, ref?: string | undefined): Promise; + deleteTag(name: string): Promise; + + status(): Promise; + checkout(treeish: string): Promise; + + addRemote(name: string, url: string): Promise; + removeRemote(name: string): Promise; + renameRemote(name: string, newName: string): Promise; + + fetch(options?: FetchOptions): Promise; + fetch(remote?: string, ref?: string, depth?: number): Promise; pull(unshallow?: boolean): Promise; - push( - remoteName?: string, - branchName?: string, - setUpstream?: boolean, - force?: ForcePushMode - ): Promise; + push(remoteName?: string, branchName?: string, setUpstream?: boolean, force?: ForcePushMode): Promise; + + blame(path: string): Promise; + log(options?: LogOptions): Promise; + + commit(message: string, opts?: CommitOptions): Promise; + merge(ref: string): Promise; + mergeAbort(): Promise; + rebase(branch: string): Promise; + + createStash(options?: { message?: string; includeUntracked?: boolean; staged?: boolean }): Promise; + applyStash(index?: number): Promise; + popStash(index?: number): Promise; + dropStash(index?: number): Promise; + + createWorktree(options?: { path?: string; commitish?: string; branch?: string }): Promise; + deleteWorktree(path: string, options?: { force?: boolean }): Promise; + + migrateChanges(sourceRepositoryPath: string, options?: { confirmation?: boolean; deleteFromSource?: boolean; untracked?: boolean }): Promise; + + generateRandomBranchName(): Promise; + + isBranchProtected(branch?: Branch): boolean; +} + +export interface RemoteSource { + readonly name: string; + readonly description?: string; + readonly url: string | string[]; +} + +export interface RemoteSourceProvider { + readonly name: string; + readonly icon?: string; // codicon name + readonly supportsQuery?: boolean; + getRemoteSources(query?: string): ProviderResult; + getBranches?(url: string): ProviderResult; + publishRepository?(repository: Repository): Promise; +} + +export interface RemoteSourcePublisher { + readonly name: string; + readonly icon?: string; // codicon name + publishRepository(repository: Repository): Promise; +} + +export interface Credentials { + readonly username: string; + readonly password: string; +} + +export interface CredentialsProvider { + getCredentials(host: Uri): ProviderResult; +} + +export interface PostCommitCommandsProvider { + getCommands(repository: Repository): Command[]; +} + +export interface PushErrorHandler { + handlePushError(repository: Repository, remote: Remote, refspec: string, error: Error & { gitErrorCode: GitErrorCodes }): Promise; +} + +export interface BranchProtection { + readonly remote: string; + readonly rules: BranchProtectionRule[]; +} + +export interface BranchProtectionRule { + readonly include?: string[]; + readonly exclude?: string[]; +} + +export interface BranchProtectionProvider { + onDidChangeBranchProtection: Event; + provideBranchProtection(): BranchProtection[]; } -export interface GitApi { +export interface AvatarQueryCommit { + readonly hash: string; + readonly authorName?: string; + readonly authorEmail?: string; +} + +export interface AvatarQuery { + readonly commits: AvatarQueryCommit[]; + readonly size: number; +} + +export interface SourceControlHistoryItemDetailsProvider { + provideAvatar(repository: Repository, query: AvatarQuery): ProviderResult>; + provideHoverCommands(repository: Repository): ProviderResult; + provideMessageLinks(repository: Repository, message: string): ProviderResult; +} + +export type APIState = 'uninitialized' | 'initialized'; + +export interface PublishEvent { + repository: Repository; + branch?: string; +} + +export interface API { + readonly state: APIState; + readonly onDidChangeState: Event; + readonly onDidPublish: Event; + readonly git: Git; readonly repositories: Repository[]; + readonly recentRepositories: Iterable; + readonly onDidOpenRepository: Event; + readonly onDidCloseRepository: Event; + + toGitUri(uri: Uri, ref: string): Uri; getRepository(uri: Uri): Repository | null; + getRepositoryRoot(uri: Uri): Promise; + getRepositoryWorkspace(uri: Uri): Promise; + init(root: Uri, options?: InitOptions): Promise; + /** + * Checks the cache of known cloned repositories, and clones if the repository is not found. + * Make sure to pass `postCloneAction` 'none' if you want to have the uri where you can find the repository returned. + * @returns The URI of a folder or workspace file which, when opened, will open the cloned repository. + */ + clone(uri: Uri, options?: CloneOptions): Promise; + openRepository(root: Uri): Promise; + + registerRemoteSourcePublisher(publisher: RemoteSourcePublisher): Disposable; + registerRemoteSourceProvider(provider: RemoteSourceProvider): Disposable; + registerCredentialsProvider(provider: CredentialsProvider): Disposable; + registerPostCommitCommandsProvider(provider: PostCommitCommandsProvider): Disposable; + registerPushErrorHandler(handler: PushErrorHandler): Disposable; + registerBranchProtectionProvider(root: Uri, provider: BranchProtectionProvider): Disposable; + registerSourceControlHistoryItemDetailsProvider(provider: SourceControlHistoryItemDetailsProvider): Disposable; } export interface GitExtension { + readonly enabled: boolean; - getAPI(version: 1): GitApi; + readonly onDidChangeEnablement: Event; + + /** + * Returns a specific API version. + * + * Throws error if git extension is disabled. You can listen to the + * [GitExtension.onDidChangeEnablement](#GitExtension.onDidChangeEnablement) event + * to know when the extension becomes enabled/disabled. + * + * @param version Version number. + * @returns API instance + */ + getAPI(version: 1): API; } + +export const enum GitErrorCodes { + BadConfigFile = 'BadConfigFile', + BadRevision = 'BadRevision', + AuthenticationFailed = 'AuthenticationFailed', + NoUserNameConfigured = 'NoUserNameConfigured', + NoUserEmailConfigured = 'NoUserEmailConfigured', + NoRemoteRepositorySpecified = 'NoRemoteRepositorySpecified', + NotAGitRepository = 'NotAGitRepository', + NotASafeGitRepository = 'NotASafeGitRepository', + NotAtRepositoryRoot = 'NotAtRepositoryRoot', + Conflict = 'Conflict', + StashConflict = 'StashConflict', + UnmergedChanges = 'UnmergedChanges', + PushRejected = 'PushRejected', + ForcePushWithLeaseRejected = 'ForcePushWithLeaseRejected', + ForcePushWithLeaseIfIncludesRejected = 'ForcePushWithLeaseIfIncludesRejected', + RemoteConnectionError = 'RemoteConnectionError', + DirtyWorkTree = 'DirtyWorkTree', + CantOpenResource = 'CantOpenResource', + GitNotFound = 'GitNotFound', + CantCreatePipe = 'CantCreatePipe', + PermissionDenied = 'PermissionDenied', + CantAccessRemote = 'CantAccessRemote', + RepositoryNotFound = 'RepositoryNotFound', + RepositoryIsLocked = 'RepositoryIsLocked', + BranchNotFullyMerged = 'BranchNotFullyMerged', + NoRemoteReference = 'NoRemoteReference', + InvalidBranchName = 'InvalidBranchName', + BranchAlreadyExists = 'BranchAlreadyExists', + NoLocalChanges = 'NoLocalChanges', + NoStashFound = 'NoStashFound', + LocalChangesOverwritten = 'LocalChangesOverwritten', + NoUpstreamBranch = 'NoUpstreamBranch', + IsInSubmodule = 'IsInSubmodule', + WrongCase = 'WrongCase', + CantLockRef = 'CantLockRef', + CantRebaseMultipleBranches = 'CantRebaseMultipleBranches', + PatchDoesNotApply = 'PatchDoesNotApply', + NoPathFound = 'NoPathFound', + UnknownPath = 'UnknownPath', + EmptyCommitMessage = 'EmptyCommitMessage', + BranchFastForwardRejected = 'BranchFastForwardRejected', + BranchNotYetBorn = 'BranchNotYetBorn', + TagConflict = 'TagConflict', + CherryPickEmpty = 'CherryPickEmpty', + CherryPickConflict = 'CherryPickConflict', + WorktreeContainsChanges = 'WorktreeContainsChanges', + WorktreeAlreadyExists = 'WorktreeAlreadyExists', + WorktreeBranchAlreadyUsed = 'WorktreeBranchAlreadyUsed' +} \ No newline at end of file