From e08a80a319d1f298d6f2f2a95e5873ae8f6c0527 Mon Sep 17 00:00:00 2001 From: Riley Smith Date: Wed, 11 Mar 2026 15:11:44 -0700 Subject: [PATCH 01/11] feat: strip html and properly create test cases --- src/interfaces/Responses.ts | 4 +- src/services/apiService.ts | 2 +- src/services/testingService.ts | 147 +++++++++++++++++++++++++-------- src/sidebarProvider.ts | 24 ++++-- 4 files changed, 133 insertions(+), 44 deletions(-) diff --git a/src/interfaces/Responses.ts b/src/interfaces/Responses.ts index 19db2d4..5e7ef1f 100644 --- a/src/interfaces/Responses.ts +++ b/src/interfaces/Responses.ts @@ -17,4 +17,6 @@ export type LoginResponse = ApiResponse<{ token: string; }>; -export type GradableResponse = ApiResponse; \ No newline at end of file +export type GradableResponse = ApiResponse<{ + [key: string]: Gradable; +}>; \ No newline at end of file diff --git a/src/services/apiService.ts b/src/services/apiService.ts index b7f6809..2ce5ede 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -117,7 +117,7 @@ export class ApiService { Array.isArray(res.data.test_cases) && res.data.test_cases.length > 0; - for (;;) { + for (; ;) { if (token?.isCancellationRequested) { throw new Error('Cancelled'); } diff --git a/src/services/testingService.ts b/src/services/testingService.ts index 03dc494..a2bb026 100644 --- a/src/services/testingService.ts +++ b/src/services/testingService.ts @@ -1,6 +1,6 @@ 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'; @@ -56,10 +56,78 @@ export class TestingService { 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; @@ -98,6 +166,49 @@ export class TestingService { } } + 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 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); + } + 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); + } + }); + + 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[] = []; @@ -137,39 +248,7 @@ export class TestingService { ); 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); - } - } + this.reportGradeableResult(run, item, data); } catch (e) { const err = e instanceof Error ? e.message : String(e); run.appendOutput(`Error: ${err}\r\n`); diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index a0c2861..9393e21 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -3,6 +3,7 @@ import { getClassesHtml } from './sidebarContent'; import { ApiService } from './services/apiService'; import { AuthService } from './services/authService'; import type { TestingService } from './services/testingService'; +import { Gradable } from './interfaces/Gradables'; export class SidebarProvider implements vscode.WebviewViewProvider { private _view?: vscode.WebviewView; @@ -109,7 +110,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { 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 })); + 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); } @@ -135,8 +136,14 @@ export class SidebarProvider implements vscode.WebviewViewProvider { 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 + + view.webview.postMessage({ command: 'gradeStarted', message: 'Submitting for grading...' }); + await this.apiService.submitVCSGradable(term, courseId, gradeableId); + + view.webview.postMessage({ command: 'gradeStarted', 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: 'displayGrade', @@ -145,21 +152,22 @@ export class SidebarProvider implements vscode.WebviewViewProvider { courseId, gradeableId, gradeDetails, - previousAttempts, // Include previous attempts + previousAttempts, } }); - // Send message to PanelProvider vscode.commands.executeCommand('extension.showGradePanel', { term, courseId, gradeableId, gradeDetails, - previousAttempts, // Include previous attempts + previousAttempts, }); + + this.testingService?.runGradeableWithResult(term, courseId, gradeableId, gradeableId, gradeDetails); } 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}` }); + vscode.window.showErrorMessage(`Failed to grade: ${error.message}`); + view.webview.postMessage({ command: 'error', message: `Failed to grade: ${error.message}` }); } } From 8ccb21f08584953e29cf92c7f999c444e38d3f06 Mon Sep 17 00:00:00 2001 From: Riley Smith Date: Wed, 11 Mar 2026 16:16:19 -0700 Subject: [PATCH 02/11] feat: first pass at autograding flow --- src/extension.ts | 4 +++- src/sidebarProvider.ts | 19 ++++++++++++++++--- src/typings/message.ts | 11 +++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 src/typings/message.ts diff --git a/src/extension.ts b/src/extension.ts index 09c7bfb..95b26d8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,11 +2,13 @@ import * as vscode from 'vscode'; import { SidebarProvider } from './sidebarProvider'; import { ApiService } from './services/apiService'; import { TestingService } from './services/testingService'; +import { GitService } from './services/gitService'; 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 gitService = new GitService(); + const sidebarProvider = new SidebarProvider(context, testingService, gitService); context.subscriptions.push( vscode.window.registerWebviewViewProvider('submittyWebview', sidebarProvider) diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index 9393e21..b00f564 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { getClassesHtml } from './sidebarContent'; import { ApiService } from './services/apiService'; import { AuthService } from './services/authService'; +import { GitService } from './services/gitService'; import type { TestingService } from './services/testingService'; import { Gradable } from './interfaces/Gradables'; @@ -13,7 +14,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider { constructor( private readonly context: vscode.ExtensionContext, - private readonly testingService?: TestingService + private readonly testingService?: TestingService, + private readonly gitService?: GitService ) { this.apiService = ApiService.getInstance(this.context, ""); this.authService = AuthService.getInstance(this.context); @@ -23,7 +25,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken - ) { + ): Promise { this._view = webviewView; webviewView.webview.options = { @@ -83,7 +85,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { } } - private async handleMessage(message: any, view: vscode.WebviewView) { + private async handleMessage(message: any, view: vscode.WebviewView): Promise { switch (message.command) { case 'fetchAndDisplayCourses': const token = await this.authService.getAuthorizationToken(); @@ -137,6 +139,17 @@ export class SidebarProvider implements vscode.WebviewViewProvider { try { this.testingService?.addGradeable(term, courseId, gradeableId, gradeableId); + if (this.gitService) { + view.webview.postMessage({ command: 'gradeStarted', message: 'Staging and committing...' }); + const commitMessage = new Date().toLocaleString(undefined, { + dateStyle: 'short', + timeStyle: 'medium', + }); + await this.gitService.commit(commitMessage, { all: true }); + view.webview.postMessage({ command: 'gradeStarted', message: 'Pushing...' }); + await this.gitService.push(); + } + view.webview.postMessage({ command: 'gradeStarted', message: 'Submitting for grading...' }); await this.apiService.submitVCSGradable(term, courseId, gradeableId); diff --git a/src/typings/message.ts b/src/typings/message.ts new file mode 100644 index 0000000..6e743b7 --- /dev/null +++ b/src/typings/message.ts @@ -0,0 +1,11 @@ +export const MessageCommand = { + FETCH_AND_DISPLAY_COURSES: 'fetchAndDisplayCourses', + GRADE: 'grade', + GRADE_STARTED: 'gradeStarted', + GRADE_COMPLETED: 'gradeCompleted', + GRADE_ERROR: 'gradeError', + GRADE_CANCELLED: 'gradeCancelled', + GRADE_PAUSED: 'gradePaused', + GRASE_RESUMED: 'gradeResumed', + GRADE_ABORTED: 'gradeAborted', +} as const; \ No newline at end of file From bd686da35245492ec64b05c739bdceba66c6bf29 Mon Sep 17 00:00:00 2001 From: Riley Smith Date: Thu, 19 Mar 2026 23:20:03 -0700 Subject: [PATCH 03/11] first pass at preloading gradeables when loading extension --- src/extension.ts | 27 ++ src/services/apiService.ts | 54 ++-- src/services/authService.ts | 43 ++- src/services/courseRepoResolver.ts | 163 ++++++++++ src/services/gitService.ts | 29 +- src/sidebar/classes.html | 8 +- src/sidebarProvider.ts | 145 +++++++-- src/typings/message.ts | 9 +- src/typings/vscode-git.d.ts | 498 ++++++++++++++++++++++++++++- 9 files changed, 891 insertions(+), 85 deletions(-) create mode 100644 src/services/courseRepoResolver.ts diff --git a/src/extension.ts b/src/extension.ts index 95b26d8..30d2371 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,17 +3,44 @@ 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 gitService = new GitService(); + const authService = AuthService.getInstance(context); const sidebarProvider = new SidebarProvider(context, testingService, gitService); 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 diff --git a/src/services/apiService.ts b/src/services/apiService.ts index 2ce5ede..d35964e 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -2,10 +2,22 @@ import * as vscode from 'vscode'; import { ApiClient } from './apiClient'; - 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; @@ -16,12 +28,12 @@ export class ApiService { } // set token for local api client - setAuthorizationToken(token: string) { + setAuthorizationToken(token: string): void { this.client.setToken(token); } // set base URL for local api client - setBaseUrl(baseUrl: string) { + setBaseUrl(baseUrl: string): void { this.client.setBaseURL(baseUrl); } @@ -43,8 +55,8 @@ export class ApiService { const token: string = response.data.data.token; return token; - } catch (error: any) { - throw new Error(error.response?.data?.message || error.message || 'Login failed.'); + } catch (error: unknown) { + throw new Error(getErrorMessage(error, 'Login failed.')); } } @@ -52,8 +64,8 @@ export class ApiService { 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.'); + } catch (error: unknown) { + throw new Error(getErrorMessage(error, 'Failed to fetch me.')); } } @@ -61,13 +73,13 @@ export class ApiService { /** * Fetch all courses for the authenticated user */ - async fetchCourses(token?: string): Promise { + async fetchCourses(_token?: string): Promise { try { const response = await this.client.get('/api/courses'); return response.data; - } catch (error: any) { + } catch (error: unknown) { console.error('Error fetching courses:', error); - throw new Error(error.response?.data?.message || 'Failed to fetch courses.'); + throw new Error(getErrorMessage(error, 'Failed to fetch courses.')); } } @@ -76,9 +88,9 @@ export class ApiService { const url = `/api/${term}/${courseId}/gradeables`; const response = await this.client.get(url); return response.data; - } catch (error: any) { + } catch (error: unknown) { console.error('Error fetching gradables:', error); - throw new Error(error.response?.data?.message || 'Failed to fetch gradables.'); + throw new Error(getErrorMessage(error, 'Failed to fetch gradables.')); } } @@ -89,9 +101,9 @@ export class ApiService { try { const response = await this.client.get(`/api/${term}/${courseId}/gradeable/${gradeableId}/values`); return response.data; - } catch (error: any) { + } catch (error: unknown) { console.error('Error fetching grade details:', error); - throw new Error(error.response?.data?.message || 'Failed to fetch grade details.'); + throw new Error(getErrorMessage(error, 'Failed to fetch grade details.')); } } @@ -140,9 +152,9 @@ export class ApiService { 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.'); + } catch (error: unknown) { + console.error('Error submitt`ing VCS gradable:', error); + throw new Error(getErrorMessage(error, 'Failed to submit VCS gradable.')); } } @@ -150,14 +162,14 @@ export class ApiService { /** * Fetch previous attempts for a specific homework assignment */ - async fetchPreviousAttempts(term: string, courseId: string, gradeableId: string): Promise { + 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); + const response = await this.client.get(url); return response.data; - } catch (error: any) { + } catch (error: unknown) { console.error('Error fetching previous attempts:', error); - throw new Error(error.response?.data?.message || 'Failed to fetch previous attempts.'); + throw new Error(getErrorMessage(error, 'Failed to fetch previous attempts.')); } } diff --git a/src/services/authService.ts b/src/services/authService.ts index bedc58f..afbd445 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -13,7 +13,7 @@ export class AuthService { this.apiService = ApiService.getInstance(context, ""); } - async initialize() { + async initialize(): Promise { console.log("Initializing AuthService"); // Get base URL from configuration @@ -31,6 +31,36 @@ export class AuthService { // 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'; + } + try { + new URL(value); + return null; + } catch { + return 'Please enter a valid URL'; + } + }, + }); + + if (!inputUrl) { + return; + } + + baseUrl = inputUrl.trim(); + + await config.update('baseUrl', baseUrl, vscode.ConfigurationTarget.Global); + this.apiService.setBaseUrl(baseUrl); + } + return; } @@ -110,19 +140,20 @@ export class AuthService { await this.login(userId.trim(), password); vscode.window.showInformationMessage('Successfully logged in to Submitty'); - } catch (error: any) { - vscode.window.showErrorMessage(`Login failed: ${error.message}`); + } 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) { + private async storeToken(token: string): Promise { await keytar.setPassword('submittyToken', 'submittyToken', token); } // get token - private async getToken() { + private async getToken(): Promise { return await keytar.getPassword('submittyToken', 'submittyToken'); } @@ -135,7 +166,7 @@ export class AuthService { const token = await this.apiService.login(userId, password); this.apiService.setAuthorizationToken(token); // store token in system keychain - this.storeToken(token); + await this.storeToken(token); return token; } diff --git a/src/services/courseRepoResolver.ts b/src/services/courseRepoResolver.ts new file mode 100644 index 0000000..fae5ada --- /dev/null +++ b/src/services/courseRepoResolver.ts @@ -0,0 +1,163 @@ +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; + // eslint-disable-next-line no-cond-assign + 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..4e4c889 100644 --- a/src/services/gitService.ts +++ b/src/services/gitService.ts @@ -1,14 +1,20 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ 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 { + private getApi(): API | null { if (this.gitApi !== null) { return this.gitApi; } @@ -33,13 +39,13 @@ export class GitService { return null; } if (uri) { - return api.getRepository(uri) ?? null; + 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] ?? null; + return api.getRepository(folder.uri) ?? api.repositories[0]; } /** @@ -50,7 +56,20 @@ export class GitService { 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[]; + }; + + 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); + + } /** 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/sidebarProvider.ts b/src/sidebarProvider.ts index b00f564..3665694 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -5,12 +5,16 @@ import { AuthService } from './services/authService'; import { GitService } from './services/gitService'; import type { TestingService } from './services/testingService'; 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; + private visibilityDisposable?: vscode.Disposable; + private isLoadingCourses: boolean = false; constructor( private readonly context: vscode.ExtensionContext, @@ -36,6 +40,15 @@ export class SidebarProvider implements vscode.WebviewViewProvider { // 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; @@ -68,6 +81,11 @@ export class SidebarProvider implements vscode.WebviewViewProvider { return; } + if (this.isLoadingCourses) { + return; + } + + this.isLoadingCourses = true; try { const token = await this.authService.getAuthorizationToken(); if (!token) { @@ -79,29 +97,75 @@ export class SidebarProvider implements vscode.WebviewViewProvider { // Fetch and display courses await this.fetchAndDisplayCourses(token, this._view); - } catch (error: any) { + } 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: ${error.message}`); + vscode.window.showErrorMessage(`Failed to load courses: ${err}`); + } finally { + this.isLoadingCourses = false; } } - private async handleMessage(message: any, view: vscode.WebviewView): Promise { - switch (message.command) { - case 'fetchAndDisplayCourses': - const token = await this.authService.getAuthorizationToken(); - if (token) { - await this.fetchAndDisplayCourses(token, view); + 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) { + 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 'grade': - await this.handleGrade(message.term, message.courseId, message.gradeableId, view); + 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; + + 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: ${message.command}`); + 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); @@ -126,12 +190,16 @@ export class SidebarProvider implements vscode.WebviewViewProvider { ); view.webview.postMessage({ - command: 'displayCourses', + command: MessageCommand.DISPLAY_COURSES, 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}` }); + } 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}` }, + }); } } @@ -140,26 +208,37 @@ export class SidebarProvider implements vscode.WebviewViewProvider { this.testingService?.addGradeable(term, courseId, gradeableId, gradeableId); if (this.gitService) { - view.webview.postMessage({ command: 'gradeStarted', message: 'Staging and committing...' }); + view.webview.postMessage({ command: MessageCommand.GRADE_STARTED, data: { message: 'Staging and committing...' } }); const commitMessage = new Date().toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'medium', }); - await this.gitService.commit(commitMessage, { all: true }); - view.webview.postMessage({ command: 'gradeStarted', message: 'Pushing...' }); - await this.gitService.push(); + 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: MessageCommand.GRADE_STARTED, + data: { message: 'No changes to commit. Skipping git push.' }, + }); + } else { + throw error; + } + } } - view.webview.postMessage({ command: 'gradeStarted', message: 'Submitting for grading...' }); + view.webview.postMessage({ command: MessageCommand.GRADE_STARTED, data: { message: 'Submitting for grading...' } }); await this.apiService.submitVCSGradable(term, courseId, gradeableId); - view.webview.postMessage({ command: 'gradeStarted', message: 'Grading in progress. Polling for results...' }); + 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: 'displayGrade', + command: MessageCommand.GRADE_COMPLETED, data: { term, courseId, @@ -169,18 +248,14 @@ export class SidebarProvider implements vscode.WebviewViewProvider { } }); - vscode.commands.executeCommand('extension.showGradePanel', { - term, - courseId, - gradeableId, - gradeDetails, - previousAttempts, - }); - this.testingService?.runGradeableWithResult(term, courseId, gradeableId, gradeableId, gradeDetails); - } catch (error: any) { - vscode.window.showErrorMessage(`Failed to grade: ${error.message}`); - view.webview.postMessage({ command: 'error', message: `Failed to grade: ${error.message}` }); + } 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}` }, + }); } } diff --git a/src/typings/message.ts b/src/typings/message.ts index 6e743b7..7a0a860 100644 --- a/src/typings/message.ts +++ b/src/typings/message.ts @@ -1,5 +1,6 @@ export const MessageCommand = { FETCH_AND_DISPLAY_COURSES: 'fetchAndDisplayCourses', + DISPLAY_COURSES: 'displayCourses', GRADE: 'grade', GRADE_STARTED: 'gradeStarted', GRADE_COMPLETED: 'gradeCompleted', @@ -8,4 +9,10 @@ export const MessageCommand = { GRADE_PAUSED: 'gradePaused', GRASE_RESUMED: 'gradeResumed', GRADE_ABORTED: 'gradeAborted', -} as const; \ No newline at end of file + ERROR: 'error', +} as const; + +export type WebViewMessage = { + command: (typeof MessageCommand)[keyof typeof MessageCommand]; + [key: string]: string | number | boolean | object | null | undefined; +}; \ No newline at end of file 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 From 01bc793296711d082d76c2064438a7c00eeed19f Mon Sep 17 00:00:00 2001 From: Riley Smith Date: Thu, 19 Mar 2026 23:42:12 -0700 Subject: [PATCH 04/11] format + lint --- .github/workflows/lint_and_test.yml | 4 +- .vscode-test.mjs | 2 +- .vscode/extensions.json | 12 +- .vscode/launch.json | 26 +- .vscode/settings.json | 16 +- .vscode/tasks.json | 32 +- CHANGELOG.md | 2 +- README.md | 6 + eslint.config.mjs | 21 +- package.json | 2 +- src/extension.ts | 78 +++-- src/interfaces/AutoGraderDetails.ts | 55 ++-- src/interfaces/Courses.ts | 14 +- src/interfaces/Gradables.ts | 26 +- src/interfaces/Responses.ts | 21 +- src/services/apiClient.ts | 268 ++++++++------- src/services/apiService.ts | 353 +++++++++++--------- src/services/authService.ts | 319 +++++++++--------- src/services/courseRepoResolver.ts | 267 +++++++-------- src/services/gitService.ts | 178 +++++----- src/services/testingService.ts | 486 +++++++++++++++------------ src/sidebar/login.html | 128 +++---- src/sidebarContent.ts | 20 +- src/sidebarProvider.ts | 494 +++++++++++++++------------- src/test/extension.test.ts | 10 +- src/typings/message.ts | 28 +- 26 files changed, 1543 insertions(+), 1325 deletions(-) diff --git a/.github/workflows/lint_and_test.yml b/.github/workflows/lint_and_test.yml index b75b3b0..79e7179 100644 --- a/.github/workflows/lint_and_test.yml +++ b/.github/workflows/lint_and_test.yml @@ -18,8 +18,8 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "20" - cache: "npm" + node-version: '20' + cache: 'npm' - name: Install dependencies run: npm ci 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 30d2371..26ebc24 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,39 +8,57 @@ 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 gitService = new GitService(); - const authService = AuthService.getInstance(context); - const sidebarProvider = new SidebarProvider(context, testingService, gitService); + 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; - } + // 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}`); - } - })(); + 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 5e7ef1f..6f6c2e0 100644 --- a/src/interfaces/Responses.ts +++ b/src/interfaces/Responses.ts @@ -1,22 +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<{ - [key: string]: Gradable; -}>; \ No newline at end of file + [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 d35964e..0706c47 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -2,181 +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 (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; } - 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; + } + return fallback; } export class 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); - } + 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' }, + } + ); - // set base URL for local api client - setBaseUrl(baseUrl: string): void { - this.client.setBaseURL(baseUrl); + const token: string = response.data.data.token; + return token; + } catch (error: unknown) { + throw new Error(getErrorMessage(error, 'Login failed.')); } - - /** - * 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: unknown) { - throw new Error(getErrorMessage(error, 'Login failed.')); - } + } + + 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.')); } - - 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.')); - } + } + + /** + * 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.')); } - - - /** - * 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.')); - } + } + + 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 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.')); - } + } + + /** + * 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 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.')); - } + } + + /** + * 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; } - - /** - * 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)); - } + ): 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: unknown) { - console.error('Error submitt`ing VCS gradable:', error); - throw new Error(getErrorMessage(error, 'Failed to submit VCS gradable.')); - } + } + + 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.')); } - - - /** - * 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: unknown) { + console.error('Error fetching previous attempts:', error); + throw new Error( + getErrorMessage(error, '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 afbd445..0e03e17 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -3,177 +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(): 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); - } - - 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'; - } - try { - new URL(value); - return null; - } catch { - return 'Please enter a valid URL'; - } - }, - }); - - if (!inputUrl) { - return; - } - - baseUrl = inputUrl.trim(); - - await config.update('baseUrl', baseUrl, vscode.ConfigurationTarget.Global); - this.apiService.setBaseUrl(baseUrl); - } - - 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: unknown) { - const err = error instanceof Error ? error.message : String(error); - vscode.window.showErrorMessage(`Login failed: ${err}`); - throw error; - } + return; } - // store token - private async storeToken(token: string): Promise { - 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(): Promise { - 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 - await 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 index fae5ada..07bb787 100644 --- a/src/services/courseRepoResolver.ts +++ b/src/services/courseRepoResolver.ts @@ -7,157 +7,164 @@ import { GitService } from './gitService'; import type { Course } from '../interfaces/Courses'; export interface CourseRepoContext { - term: string; - courseId: string; + 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, ''); + 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; - } + 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; + const gitEntryPath = path.join(repoRootPath, '.git'); + if (!fs.existsSync(gitEntryPath)) { + return null; + } + + try { + const stat = fs.statSync(gitEntryPath); + if (stat.isDirectory()) { + return gitEntryPath; } - 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 { + 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; - // eslint-disable-next-line no-cond-assign - while ((match = urlRegex.exec(gitConfigText))) { - const rawUrl = match[1]?.trim(); - if (rawUrl) { - urls.push(rawUrl); - } + 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; + 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 }; + 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 4e4c889..c32ef19 100644 --- a/src/services/gitService.ts +++ b/src/services/gitService.ts @@ -1,10 +1,10 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ import * as vscode from 'vscode'; -import type { GitExtension, Repository, CommitOptions, ForcePushMode } from '../typings/vscode-git'; +import type { + GitExtension, + Repository, + CommitOptions, + ForcePushMode, +} from '../typings/vscode-git'; import { API } from '../typings/vscode-git'; /** @@ -12,95 +12,103 @@ import { API } from '../typings/vscode-git'; * push, pull, and commit in the current workspace repository. */ export class GitService { - private gitApi: API | null = null; + private gitApi: API | null = 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; - } + private getApi(): API | null { + if (this.gitApi !== null) { + return this.gitApi; } - - /** - * 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]; + 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; + } + } - /** - * 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[]; - }; + /** + * 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]; + } - 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); + /** + * 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[]; + }; + 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 a2bb026..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 { AutoGraderDetails, AutoGraderDetailsData, TestCase, Autocheck } from '../interfaces/AutoGraderDetails'; +import type { + AutoGraderDetails, + AutoGraderDetailsData, + TestCase, + Autocheck, +} from '../interfaces/AutoGraderDetails'; const CONTROLLER_ID = 'submittyAutograder'; const CONTROLLER_LABEL = 'Submitty Autograder'; @@ -9,253 +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(); + 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); + 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; + 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); + /** + * 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(); + 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 getGradeableMeta(item: vscode.TestItem): GradeableMeta | undefined { - return this.gradeableMeta.get(item); + 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'); + } - /** - * 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(); + /** + * 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 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'); + 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); } + } - /** - * 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 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 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 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); - } - 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 async runHandler( + request: vscode.TestRunRequest, + token: vscode.CancellationToken + ): Promise { + const run = this.controller.createTestRun(request); + const queue: vscode.TestItem[] = []; - 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); + if (request.include) { + request.include.forEach(t => { + if (t.id === ROOT_ID) { + this.rootItem.children.forEach(c => queue.push(c)); } else { - if (allPassed) { - run.passed(item, Date.now() - start); - } else { - run.failed(item, new vscode.TestMessage('Some test cases failed.'), Date.now() - start); - } + queue.push(t); } + }); + } else { + this.rootItem.children.forEach(t => 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 { - 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; + } - while (queue.length > 0 && !token.isCancellationRequested) { - const item = queue.shift()!; - if (request.exclude?.includes(item)) { - continue; - } + const meta = this.getGradeableMeta(item); + if (!meta) { + continue; + } - const meta = this.getGradeableMeta(item); - if (!meta) { - continue; - } + run.started(item); + run.appendOutput(`Polling grade details for ${item.label}...\r\n`); - 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(); + 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/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 3665694..fdeb712 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -9,258 +9,311 @@ 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; - private visibilityDisposable?: vscode.Disposable; - private isLoadingCourses: boolean = false; + 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); - } + 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; + 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')], - }; + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.context.extensionUri, 'src', 'webview'), + ], + }; - // Initially show blank screen - webviewView.webview.html = this.getBlankHtml(); + // 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(); - } - }); + // 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(); + // 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(); - } + // 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; + } - if (this.isLoadingCourses) { - return; - } + this.isLoadingCourses = true; + try { + const token = await this.authService.getAuthorizationToken(); + if (!token) { + return; + } - this.isLoadingCourses = true; - try { - const token = await this.authService.getAuthorizationToken(); - if (!token) { - return; - } + // Show classes HTML + this._view.webview.html = getClassesHtml(this.context); - // 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; + } + } - // 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; } - 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) { + 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; - switch (msg.command) { - case MessageCommand.FETCH_AND_DISPLAY_COURSES: - try { - 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; - - 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; + 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 = 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, - }; - }) + 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: MessageCommand.DISPLAY_COURSES, - data: { courses: coursesWithGradables }, - }); + 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 + ); + + 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); - vscode.window.showErrorMessage(`Failed to fetch courses: ${err}`); + const err = error instanceof Error ? error.message : String(error); + if (err === 'No changes to commit.') { view.webview.postMessage({ - command: MessageCommand.ERROR, - data: { message: `Failed to fetch courses: ${err}` }, + command: MessageCommand.GRADE_STARTED, + data: { message: 'No changes to commit. Skipping git push.' }, }); + } else { + throw error; + } } - } + } - private async handleGrade(term: string, courseId: string, gradeableId: string, view: vscode.WebviewView): Promise { - try { - this.testingService?.addGradeable(term, courseId, gradeableId, gradeableId); + view.webview.postMessage({ + command: MessageCommand.GRADE_STARTED, + data: { message: 'Submitting for grading...' }, + }); + await this.apiService.submitVCSGradable(term, courseId, 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: MessageCommand.GRADE_STARTED, - data: { message: 'No changes to commit. Skipping git push.' }, - }); - } 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_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, - } - }); + view.webview.postMessage({ + command: MessageCommand.GRADE_COMPLETED, + data: { + term, + courseId, + gradeableId, + gradeDetails, + previousAttempts, + }, + }); - 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}` }, - }); - } + 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 ` @@ -279,6 +332,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 index 7a0a860..74cdd08 100644 --- a/src/typings/message.ts +++ b/src/typings/message.ts @@ -1,18 +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', + 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; -}; \ No newline at end of file + command: (typeof MessageCommand)[keyof typeof MessageCommand]; + [key: string]: string | number | boolean | object | null | undefined; +}; From 50620b98fdcd25a459a2eb9d7b07f2a7a7b87913 Mon Sep 17 00:00:00 2001 From: Riley Smith Date: Fri, 20 Mar 2026 14:15:27 -0700 Subject: [PATCH 05/11] fix double import --- src/sidebarProvider.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index fdeb712..935a8f7 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -3,7 +3,6 @@ import { getClassesHtml } from './sidebarContent'; import { ApiService } from './services/apiService'; import { AuthService } from './services/authService'; import { GitService } from './services/gitService'; -import type { TestingService } from './services/testingService'; import { Gradable } from './interfaces/Gradables'; import { TestingService } from './services/testingService'; import { MessageCommand } from './typings/message'; @@ -295,13 +294,15 @@ export class SidebarProvider implements vscode.WebviewViewProvider { }, }); - this.testingService?.runGradeableWithResult( - term, - courseId, - gradeableId, - gradeableId, - gradeDetails - ); + 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}`); From e8aced6a755760ef444858c7601622730d209082 Mon Sep 17 00:00:00 2001 From: Riley Smith <129999894+rileyallyn@users.noreply.github.com> Date: Fri, 20 Mar 2026 14:52:25 -0700 Subject: [PATCH 06/11] [Refactor:System] Fix GitHub Actions test runner (#4) * fix tests not running * setup caching --- .github/workflows/lint_and_test.yml | 32 +++++++++++++++++++---------- 1 file changed, 21 insertions(+), 11 deletions(-) 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' From 3580a08ae0949379c24e8203862f0f641a78a988 Mon Sep 17 00:00:00 2001 From: Riley Smith Date: Wed, 11 Mar 2026 15:11:44 -0700 Subject: [PATCH 07/11] feat: strip html and properly create test cases --- src/interfaces/Responses.ts | 4 +- src/services/apiService.ts | 2 +- src/services/testingService.ts | 147 +++++++++++++++++++++++++-------- src/sidebarProvider.ts | 24 ++++-- 4 files changed, 133 insertions(+), 44 deletions(-) diff --git a/src/interfaces/Responses.ts b/src/interfaces/Responses.ts index 19db2d4..5e7ef1f 100644 --- a/src/interfaces/Responses.ts +++ b/src/interfaces/Responses.ts @@ -17,4 +17,6 @@ export type LoginResponse = ApiResponse<{ token: string; }>; -export type GradableResponse = ApiResponse; \ No newline at end of file +export type GradableResponse = ApiResponse<{ + [key: string]: Gradable; +}>; \ No newline at end of file diff --git a/src/services/apiService.ts b/src/services/apiService.ts index b7f6809..2ce5ede 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -117,7 +117,7 @@ export class ApiService { Array.isArray(res.data.test_cases) && res.data.test_cases.length > 0; - for (;;) { + for (; ;) { if (token?.isCancellationRequested) { throw new Error('Cancelled'); } diff --git a/src/services/testingService.ts b/src/services/testingService.ts index 03dc494..a2bb026 100644 --- a/src/services/testingService.ts +++ b/src/services/testingService.ts @@ -1,6 +1,6 @@ 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'; @@ -56,10 +56,78 @@ export class TestingService { 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; @@ -98,6 +166,49 @@ export class TestingService { } } + 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 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); + } + 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); + } + }); + + 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[] = []; @@ -137,39 +248,7 @@ export class TestingService { ); 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); - } - } + this.reportGradeableResult(run, item, data); } catch (e) { const err = e instanceof Error ? e.message : String(e); run.appendOutput(`Error: ${err}\r\n`); diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index a0c2861..9393e21 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -3,6 +3,7 @@ import { getClassesHtml } from './sidebarContent'; import { ApiService } from './services/apiService'; import { AuthService } from './services/authService'; import type { TestingService } from './services/testingService'; +import { Gradable } from './interfaces/Gradables'; export class SidebarProvider implements vscode.WebviewViewProvider { private _view?: vscode.WebviewView; @@ -109,7 +110,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { 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 })); + 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); } @@ -135,8 +136,14 @@ export class SidebarProvider implements vscode.WebviewViewProvider { 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 + + view.webview.postMessage({ command: 'gradeStarted', message: 'Submitting for grading...' }); + await this.apiService.submitVCSGradable(term, courseId, gradeableId); + + view.webview.postMessage({ command: 'gradeStarted', 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: 'displayGrade', @@ -145,21 +152,22 @@ export class SidebarProvider implements vscode.WebviewViewProvider { courseId, gradeableId, gradeDetails, - previousAttempts, // Include previous attempts + previousAttempts, } }); - // Send message to PanelProvider vscode.commands.executeCommand('extension.showGradePanel', { term, courseId, gradeableId, gradeDetails, - previousAttempts, // Include previous attempts + previousAttempts, }); + + this.testingService?.runGradeableWithResult(term, courseId, gradeableId, gradeableId, gradeDetails); } 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}` }); + vscode.window.showErrorMessage(`Failed to grade: ${error.message}`); + view.webview.postMessage({ command: 'error', message: `Failed to grade: ${error.message}` }); } } From 0fe341229b9eb62a60b0a0dd516b7176c776d87e Mon Sep 17 00:00:00 2001 From: Riley Smith Date: Wed, 11 Mar 2026 16:16:19 -0700 Subject: [PATCH 08/11] feat: first pass at autograding flow --- src/extension.ts | 4 +++- src/sidebarProvider.ts | 19 ++++++++++++++++--- src/typings/message.ts | 11 +++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) create mode 100644 src/typings/message.ts diff --git a/src/extension.ts b/src/extension.ts index 09c7bfb..95b26d8 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -2,11 +2,13 @@ import * as vscode from 'vscode'; import { SidebarProvider } from './sidebarProvider'; import { ApiService } from './services/apiService'; import { TestingService } from './services/testingService'; +import { GitService } from './services/gitService'; 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 gitService = new GitService(); + const sidebarProvider = new SidebarProvider(context, testingService, gitService); context.subscriptions.push( vscode.window.registerWebviewViewProvider('submittyWebview', sidebarProvider) diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index 9393e21..b00f564 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -2,6 +2,7 @@ import * as vscode from 'vscode'; import { getClassesHtml } from './sidebarContent'; import { ApiService } from './services/apiService'; import { AuthService } from './services/authService'; +import { GitService } from './services/gitService'; import type { TestingService } from './services/testingService'; import { Gradable } from './interfaces/Gradables'; @@ -13,7 +14,8 @@ export class SidebarProvider implements vscode.WebviewViewProvider { constructor( private readonly context: vscode.ExtensionContext, - private readonly testingService?: TestingService + private readonly testingService?: TestingService, + private readonly gitService?: GitService ) { this.apiService = ApiService.getInstance(this.context, ""); this.authService = AuthService.getInstance(this.context); @@ -23,7 +25,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { webviewView: vscode.WebviewView, _context: vscode.WebviewViewResolveContext, _token: vscode.CancellationToken - ) { + ): Promise { this._view = webviewView; webviewView.webview.options = { @@ -83,7 +85,7 @@ export class SidebarProvider implements vscode.WebviewViewProvider { } } - private async handleMessage(message: any, view: vscode.WebviewView) { + private async handleMessage(message: any, view: vscode.WebviewView): Promise { switch (message.command) { case 'fetchAndDisplayCourses': const token = await this.authService.getAuthorizationToken(); @@ -137,6 +139,17 @@ export class SidebarProvider implements vscode.WebviewViewProvider { try { this.testingService?.addGradeable(term, courseId, gradeableId, gradeableId); + if (this.gitService) { + view.webview.postMessage({ command: 'gradeStarted', message: 'Staging and committing...' }); + const commitMessage = new Date().toLocaleString(undefined, { + dateStyle: 'short', + timeStyle: 'medium', + }); + await this.gitService.commit(commitMessage, { all: true }); + view.webview.postMessage({ command: 'gradeStarted', message: 'Pushing...' }); + await this.gitService.push(); + } + view.webview.postMessage({ command: 'gradeStarted', message: 'Submitting for grading...' }); await this.apiService.submitVCSGradable(term, courseId, gradeableId); diff --git a/src/typings/message.ts b/src/typings/message.ts new file mode 100644 index 0000000..6e743b7 --- /dev/null +++ b/src/typings/message.ts @@ -0,0 +1,11 @@ +export const MessageCommand = { + FETCH_AND_DISPLAY_COURSES: 'fetchAndDisplayCourses', + GRADE: 'grade', + GRADE_STARTED: 'gradeStarted', + GRADE_COMPLETED: 'gradeCompleted', + GRADE_ERROR: 'gradeError', + GRADE_CANCELLED: 'gradeCancelled', + GRADE_PAUSED: 'gradePaused', + GRASE_RESUMED: 'gradeResumed', + GRADE_ABORTED: 'gradeAborted', +} as const; \ No newline at end of file From 989963e81f7512c21f89f1ac05a69f1220c410bc Mon Sep 17 00:00:00 2001 From: Riley Smith Date: Thu, 19 Mar 2026 23:20:03 -0700 Subject: [PATCH 09/11] first pass at preloading gradeables when loading extension --- src/extension.ts | 27 ++ src/services/apiService.ts | 54 ++-- src/services/authService.ts | 43 ++- src/services/courseRepoResolver.ts | 163 ++++++++++ src/services/gitService.ts | 29 +- src/sidebar/classes.html | 8 +- src/sidebarProvider.ts | 145 +++++++-- src/typings/message.ts | 9 +- src/typings/vscode-git.d.ts | 498 ++++++++++++++++++++++++++++- 9 files changed, 891 insertions(+), 85 deletions(-) create mode 100644 src/services/courseRepoResolver.ts diff --git a/src/extension.ts b/src/extension.ts index 95b26d8..30d2371 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -3,17 +3,44 @@ 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 gitService = new GitService(); + const authService = AuthService.getInstance(context); const sidebarProvider = new SidebarProvider(context, testingService, gitService); 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 diff --git a/src/services/apiService.ts b/src/services/apiService.ts index 2ce5ede..d35964e 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -2,10 +2,22 @@ import * as vscode from 'vscode'; import { ApiClient } from './apiClient'; - 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; @@ -16,12 +28,12 @@ export class ApiService { } // set token for local api client - setAuthorizationToken(token: string) { + setAuthorizationToken(token: string): void { this.client.setToken(token); } // set base URL for local api client - setBaseUrl(baseUrl: string) { + setBaseUrl(baseUrl: string): void { this.client.setBaseURL(baseUrl); } @@ -43,8 +55,8 @@ export class ApiService { const token: string = response.data.data.token; return token; - } catch (error: any) { - throw new Error(error.response?.data?.message || error.message || 'Login failed.'); + } catch (error: unknown) { + throw new Error(getErrorMessage(error, 'Login failed.')); } } @@ -52,8 +64,8 @@ export class ApiService { 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.'); + } catch (error: unknown) { + throw new Error(getErrorMessage(error, 'Failed to fetch me.')); } } @@ -61,13 +73,13 @@ export class ApiService { /** * Fetch all courses for the authenticated user */ - async fetchCourses(token?: string): Promise { + async fetchCourses(_token?: string): Promise { try { const response = await this.client.get('/api/courses'); return response.data; - } catch (error: any) { + } catch (error: unknown) { console.error('Error fetching courses:', error); - throw new Error(error.response?.data?.message || 'Failed to fetch courses.'); + throw new Error(getErrorMessage(error, 'Failed to fetch courses.')); } } @@ -76,9 +88,9 @@ export class ApiService { const url = `/api/${term}/${courseId}/gradeables`; const response = await this.client.get(url); return response.data; - } catch (error: any) { + } catch (error: unknown) { console.error('Error fetching gradables:', error); - throw new Error(error.response?.data?.message || 'Failed to fetch gradables.'); + throw new Error(getErrorMessage(error, 'Failed to fetch gradables.')); } } @@ -89,9 +101,9 @@ export class ApiService { try { const response = await this.client.get(`/api/${term}/${courseId}/gradeable/${gradeableId}/values`); return response.data; - } catch (error: any) { + } catch (error: unknown) { console.error('Error fetching grade details:', error); - throw new Error(error.response?.data?.message || 'Failed to fetch grade details.'); + throw new Error(getErrorMessage(error, 'Failed to fetch grade details.')); } } @@ -140,9 +152,9 @@ export class ApiService { 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.'); + } catch (error: unknown) { + console.error('Error submitt`ing VCS gradable:', error); + throw new Error(getErrorMessage(error, 'Failed to submit VCS gradable.')); } } @@ -150,14 +162,14 @@ export class ApiService { /** * Fetch previous attempts for a specific homework assignment */ - async fetchPreviousAttempts(term: string, courseId: string, gradeableId: string): Promise { + 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); + const response = await this.client.get(url); return response.data; - } catch (error: any) { + } catch (error: unknown) { console.error('Error fetching previous attempts:', error); - throw new Error(error.response?.data?.message || 'Failed to fetch previous attempts.'); + throw new Error(getErrorMessage(error, 'Failed to fetch previous attempts.')); } } diff --git a/src/services/authService.ts b/src/services/authService.ts index bedc58f..afbd445 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -13,7 +13,7 @@ export class AuthService { this.apiService = ApiService.getInstance(context, ""); } - async initialize() { + async initialize(): Promise { console.log("Initializing AuthService"); // Get base URL from configuration @@ -31,6 +31,36 @@ export class AuthService { // 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'; + } + try { + new URL(value); + return null; + } catch { + return 'Please enter a valid URL'; + } + }, + }); + + if (!inputUrl) { + return; + } + + baseUrl = inputUrl.trim(); + + await config.update('baseUrl', baseUrl, vscode.ConfigurationTarget.Global); + this.apiService.setBaseUrl(baseUrl); + } + return; } @@ -110,19 +140,20 @@ export class AuthService { await this.login(userId.trim(), password); vscode.window.showInformationMessage('Successfully logged in to Submitty'); - } catch (error: any) { - vscode.window.showErrorMessage(`Login failed: ${error.message}`); + } 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) { + private async storeToken(token: string): Promise { await keytar.setPassword('submittyToken', 'submittyToken', token); } // get token - private async getToken() { + private async getToken(): Promise { return await keytar.getPassword('submittyToken', 'submittyToken'); } @@ -135,7 +166,7 @@ export class AuthService { const token = await this.apiService.login(userId, password); this.apiService.setAuthorizationToken(token); // store token in system keychain - this.storeToken(token); + await this.storeToken(token); return token; } diff --git a/src/services/courseRepoResolver.ts b/src/services/courseRepoResolver.ts new file mode 100644 index 0000000..fae5ada --- /dev/null +++ b/src/services/courseRepoResolver.ts @@ -0,0 +1,163 @@ +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; + // eslint-disable-next-line no-cond-assign + 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..4e4c889 100644 --- a/src/services/gitService.ts +++ b/src/services/gitService.ts @@ -1,14 +1,20 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ +/* eslint-disable @typescript-eslint/no-unsafe-call */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +/* eslint-disable @typescript-eslint/no-unsafe-return */ 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 { + private getApi(): API | null { if (this.gitApi !== null) { return this.gitApi; } @@ -33,13 +39,13 @@ export class GitService { return null; } if (uri) { - return api.getRepository(uri) ?? null; + 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] ?? null; + return api.getRepository(folder.uri) ?? api.repositories[0]; } /** @@ -50,7 +56,20 @@ export class GitService { 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[]; + }; + + 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); + + } /** 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/sidebarProvider.ts b/src/sidebarProvider.ts index b00f564..3665694 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -5,12 +5,16 @@ import { AuthService } from './services/authService'; import { GitService } from './services/gitService'; import type { TestingService } from './services/testingService'; 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; + private visibilityDisposable?: vscode.Disposable; + private isLoadingCourses: boolean = false; constructor( private readonly context: vscode.ExtensionContext, @@ -36,6 +40,15 @@ export class SidebarProvider implements vscode.WebviewViewProvider { // 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; @@ -68,6 +81,11 @@ export class SidebarProvider implements vscode.WebviewViewProvider { return; } + if (this.isLoadingCourses) { + return; + } + + this.isLoadingCourses = true; try { const token = await this.authService.getAuthorizationToken(); if (!token) { @@ -79,29 +97,75 @@ export class SidebarProvider implements vscode.WebviewViewProvider { // Fetch and display courses await this.fetchAndDisplayCourses(token, this._view); - } catch (error: any) { + } 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: ${error.message}`); + vscode.window.showErrorMessage(`Failed to load courses: ${err}`); + } finally { + this.isLoadingCourses = false; } } - private async handleMessage(message: any, view: vscode.WebviewView): Promise { - switch (message.command) { - case 'fetchAndDisplayCourses': - const token = await this.authService.getAuthorizationToken(); - if (token) { - await this.fetchAndDisplayCourses(token, view); + 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) { + 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 'grade': - await this.handleGrade(message.term, message.courseId, message.gradeableId, view); + 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; + + 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: ${message.command}`); + 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); @@ -126,12 +190,16 @@ export class SidebarProvider implements vscode.WebviewViewProvider { ); view.webview.postMessage({ - command: 'displayCourses', + command: MessageCommand.DISPLAY_COURSES, 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}` }); + } 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}` }, + }); } } @@ -140,26 +208,37 @@ export class SidebarProvider implements vscode.WebviewViewProvider { this.testingService?.addGradeable(term, courseId, gradeableId, gradeableId); if (this.gitService) { - view.webview.postMessage({ command: 'gradeStarted', message: 'Staging and committing...' }); + view.webview.postMessage({ command: MessageCommand.GRADE_STARTED, data: { message: 'Staging and committing...' } }); const commitMessage = new Date().toLocaleString(undefined, { dateStyle: 'short', timeStyle: 'medium', }); - await this.gitService.commit(commitMessage, { all: true }); - view.webview.postMessage({ command: 'gradeStarted', message: 'Pushing...' }); - await this.gitService.push(); + 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: MessageCommand.GRADE_STARTED, + data: { message: 'No changes to commit. Skipping git push.' }, + }); + } else { + throw error; + } + } } - view.webview.postMessage({ command: 'gradeStarted', message: 'Submitting for grading...' }); + view.webview.postMessage({ command: MessageCommand.GRADE_STARTED, data: { message: 'Submitting for grading...' } }); await this.apiService.submitVCSGradable(term, courseId, gradeableId); - view.webview.postMessage({ command: 'gradeStarted', message: 'Grading in progress. Polling for results...' }); + 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: 'displayGrade', + command: MessageCommand.GRADE_COMPLETED, data: { term, courseId, @@ -169,18 +248,14 @@ export class SidebarProvider implements vscode.WebviewViewProvider { } }); - vscode.commands.executeCommand('extension.showGradePanel', { - term, - courseId, - gradeableId, - gradeDetails, - previousAttempts, - }); - this.testingService?.runGradeableWithResult(term, courseId, gradeableId, gradeableId, gradeDetails); - } catch (error: any) { - vscode.window.showErrorMessage(`Failed to grade: ${error.message}`); - view.webview.postMessage({ command: 'error', message: `Failed to grade: ${error.message}` }); + } 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}` }, + }); } } diff --git a/src/typings/message.ts b/src/typings/message.ts index 6e743b7..7a0a860 100644 --- a/src/typings/message.ts +++ b/src/typings/message.ts @@ -1,5 +1,6 @@ export const MessageCommand = { FETCH_AND_DISPLAY_COURSES: 'fetchAndDisplayCourses', + DISPLAY_COURSES: 'displayCourses', GRADE: 'grade', GRADE_STARTED: 'gradeStarted', GRADE_COMPLETED: 'gradeCompleted', @@ -8,4 +9,10 @@ export const MessageCommand = { GRADE_PAUSED: 'gradePaused', GRASE_RESUMED: 'gradeResumed', GRADE_ABORTED: 'gradeAborted', -} as const; \ No newline at end of file + ERROR: 'error', +} as const; + +export type WebViewMessage = { + command: (typeof MessageCommand)[keyof typeof MessageCommand]; + [key: string]: string | number | boolean | object | null | undefined; +}; \ No newline at end of file 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 From 368d9d1894122e359a0414dc4c307364ef25c1f2 Mon Sep 17 00:00:00 2001 From: Riley Smith Date: Thu, 19 Mar 2026 23:42:12 -0700 Subject: [PATCH 10/11] format + lint --- .vscode-test.mjs | 2 +- .vscode/extensions.json | 12 +- .vscode/launch.json | 26 +- .vscode/settings.json | 16 +- .vscode/tasks.json | 32 +- CHANGELOG.md | 2 +- README.md | 6 + eslint.config.mjs | 21 +- package.json | 2 +- src/extension.ts | 78 +++-- src/interfaces/AutoGraderDetails.ts | 55 ++-- src/interfaces/Courses.ts | 14 +- src/interfaces/Gradables.ts | 26 +- src/interfaces/Responses.ts | 21 +- src/services/apiClient.ts | 268 ++++++++------- src/services/apiService.ts | 353 +++++++++++--------- src/services/authService.ts | 319 +++++++++--------- src/services/courseRepoResolver.ts | 267 +++++++-------- src/services/gitService.ts | 178 +++++----- src/services/testingService.ts | 486 +++++++++++++++------------ src/sidebar/login.html | 128 +++---- src/sidebarContent.ts | 20 +- src/sidebarProvider.ts | 494 +++++++++++++++------------- src/test/extension.test.ts | 10 +- src/typings/message.ts | 28 +- 25 files changed, 1541 insertions(+), 1323 deletions(-) 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 30d2371..26ebc24 100644 --- a/src/extension.ts +++ b/src/extension.ts @@ -8,39 +8,57 @@ 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 gitService = new GitService(); - const authService = AuthService.getInstance(context); - const sidebarProvider = new SidebarProvider(context, testingService, gitService); + 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; - } + // 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}`); - } - })(); + 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 5e7ef1f..6f6c2e0 100644 --- a/src/interfaces/Responses.ts +++ b/src/interfaces/Responses.ts @@ -1,22 +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<{ - [key: string]: Gradable; -}>; \ No newline at end of file + [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 d35964e..0706c47 100644 --- a/src/services/apiService.ts +++ b/src/services/apiService.ts @@ -2,181 +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 (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; } - 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; + } + return fallback; } export class 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); - } + 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' }, + } + ); - // set base URL for local api client - setBaseUrl(baseUrl: string): void { - this.client.setBaseURL(baseUrl); + const token: string = response.data.data.token; + return token; + } catch (error: unknown) { + throw new Error(getErrorMessage(error, 'Login failed.')); } - - /** - * 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: unknown) { - throw new Error(getErrorMessage(error, 'Login failed.')); - } + } + + 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.')); } - - 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.')); - } + } + + /** + * 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.')); } - - - /** - * 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.')); - } + } + + 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 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.')); - } + } + + /** + * 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 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.')); - } + } + + /** + * 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; } - - /** - * 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)); - } + ): 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: unknown) { - console.error('Error submitt`ing VCS gradable:', error); - throw new Error(getErrorMessage(error, 'Failed to submit VCS gradable.')); - } + } + + 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.')); } - - - /** - * 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: unknown) { + console.error('Error fetching previous attempts:', error); + throw new Error( + getErrorMessage(error, '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 afbd445..0e03e17 100644 --- a/src/services/authService.ts +++ b/src/services/authService.ts @@ -3,177 +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(): 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); - } - - 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'; - } - try { - new URL(value); - return null; - } catch { - return 'Please enter a valid URL'; - } - }, - }); - - if (!inputUrl) { - return; - } - - baseUrl = inputUrl.trim(); - - await config.update('baseUrl', baseUrl, vscode.ConfigurationTarget.Global); - this.apiService.setBaseUrl(baseUrl); - } - - 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: unknown) { - const err = error instanceof Error ? error.message : String(error); - vscode.window.showErrorMessage(`Login failed: ${err}`); - throw error; - } + return; } - // store token - private async storeToken(token: string): Promise { - 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(): Promise { - 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 - await 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 index fae5ada..07bb787 100644 --- a/src/services/courseRepoResolver.ts +++ b/src/services/courseRepoResolver.ts @@ -7,157 +7,164 @@ import { GitService } from './gitService'; import type { Course } from '../interfaces/Courses'; export interface CourseRepoContext { - term: string; - courseId: string; + 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, ''); + 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; - } + 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; + const gitEntryPath = path.join(repoRootPath, '.git'); + if (!fs.existsSync(gitEntryPath)) { + return null; + } + + try { + const stat = fs.statSync(gitEntryPath); + if (stat.isDirectory()) { + return gitEntryPath; } - 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 { + 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; - // eslint-disable-next-line no-cond-assign - while ((match = urlRegex.exec(gitConfigText))) { - const rawUrl = match[1]?.trim(); - if (rawUrl) { - urls.push(rawUrl); - } + 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; + 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 }; + 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 4e4c889..c32ef19 100644 --- a/src/services/gitService.ts +++ b/src/services/gitService.ts @@ -1,10 +1,10 @@ -/* eslint-disable @typescript-eslint/no-unsafe-assignment */ -/* eslint-disable @typescript-eslint/no-redundant-type-constituents */ -/* eslint-disable @typescript-eslint/no-unsafe-call */ -/* eslint-disable @typescript-eslint/no-unsafe-member-access */ -/* eslint-disable @typescript-eslint/no-unsafe-return */ import * as vscode from 'vscode'; -import type { GitExtension, Repository, CommitOptions, ForcePushMode } from '../typings/vscode-git'; +import type { + GitExtension, + Repository, + CommitOptions, + ForcePushMode, +} from '../typings/vscode-git'; import { API } from '../typings/vscode-git'; /** @@ -12,95 +12,103 @@ import { API } from '../typings/vscode-git'; * push, pull, and commit in the current workspace repository. */ export class GitService { - private gitApi: API | null = null; + private gitApi: API | null = 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; - } + private getApi(): API | null { + if (this.gitApi !== null) { + return this.gitApi; } - - /** - * 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]; + 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; + } + } - /** - * 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[]; - }; + /** + * 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]; + } - 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); + /** + * 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[]; + }; + 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 a2bb026..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 { AutoGraderDetails, AutoGraderDetailsData, TestCase, Autocheck } from '../interfaces/AutoGraderDetails'; +import type { + AutoGraderDetails, + AutoGraderDetailsData, + TestCase, + Autocheck, +} from '../interfaces/AutoGraderDetails'; const CONTROLLER_ID = 'submittyAutograder'; const CONTROLLER_LABEL = 'Submitty Autograder'; @@ -9,253 +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(); + 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); + 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; + 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); + /** + * 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(); + 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 getGradeableMeta(item: vscode.TestItem): GradeableMeta | undefined { - return this.gradeableMeta.get(item); + 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'); + } - /** - * 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(); + /** + * 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 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'); + 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); } + } - /** - * 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 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 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 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); - } - 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 async runHandler( + request: vscode.TestRunRequest, + token: vscode.CancellationToken + ): Promise { + const run = this.controller.createTestRun(request); + const queue: vscode.TestItem[] = []; - 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); + if (request.include) { + request.include.forEach(t => { + if (t.id === ROOT_ID) { + this.rootItem.children.forEach(c => queue.push(c)); } else { - if (allPassed) { - run.passed(item, Date.now() - start); - } else { - run.failed(item, new vscode.TestMessage('Some test cases failed.'), Date.now() - start); - } + queue.push(t); } + }); + } else { + this.rootItem.children.forEach(t => 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 { - 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; + } - while (queue.length > 0 && !token.isCancellationRequested) { - const item = queue.shift()!; - if (request.exclude?.includes(item)) { - continue; - } + const meta = this.getGradeableMeta(item); + if (!meta) { + continue; + } - const meta = this.getGradeableMeta(item); - if (!meta) { - continue; - } + run.started(item); + run.appendOutput(`Polling grade details for ${item.label}...\r\n`); - 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(); + 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/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 3665694..fdeb712 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -9,258 +9,311 @@ 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; - private visibilityDisposable?: vscode.Disposable; - private isLoadingCourses: boolean = false; + 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); - } + 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; + 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')], - }; + webviewView.webview.options = { + enableScripts: true, + localResourceRoots: [ + vscode.Uri.joinPath(this.context.extensionUri, 'src', 'webview'), + ], + }; - // Initially show blank screen - webviewView.webview.html = this.getBlankHtml(); + // 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(); - } - }); + // 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(); + // 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(); - } + // 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; + } - if (this.isLoadingCourses) { - return; - } + this.isLoadingCourses = true; + try { + const token = await this.authService.getAuthorizationToken(); + if (!token) { + return; + } - this.isLoadingCourses = true; - try { - const token = await this.authService.getAuthorizationToken(); - if (!token) { - return; - } + // Show classes HTML + this._view.webview.html = getClassesHtml(this.context); - // 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; + } + } - // 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; } - 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) { + 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; - switch (msg.command) { - case MessageCommand.FETCH_AND_DISPLAY_COURSES: - try { - 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; - - 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; + 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 = 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, - }; - }) + 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: MessageCommand.DISPLAY_COURSES, - data: { courses: coursesWithGradables }, - }); + 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 + ); + + 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); - vscode.window.showErrorMessage(`Failed to fetch courses: ${err}`); + const err = error instanceof Error ? error.message : String(error); + if (err === 'No changes to commit.') { view.webview.postMessage({ - command: MessageCommand.ERROR, - data: { message: `Failed to fetch courses: ${err}` }, + command: MessageCommand.GRADE_STARTED, + data: { message: 'No changes to commit. Skipping git push.' }, }); + } else { + throw error; + } } - } + } - private async handleGrade(term: string, courseId: string, gradeableId: string, view: vscode.WebviewView): Promise { - try { - this.testingService?.addGradeable(term, courseId, gradeableId, gradeableId); + view.webview.postMessage({ + command: MessageCommand.GRADE_STARTED, + data: { message: 'Submitting for grading...' }, + }); + await this.apiService.submitVCSGradable(term, courseId, 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: MessageCommand.GRADE_STARTED, - data: { message: 'No changes to commit. Skipping git push.' }, - }); - } 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_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, - } - }); + view.webview.postMessage({ + command: MessageCommand.GRADE_COMPLETED, + data: { + term, + courseId, + gradeableId, + gradeDetails, + previousAttempts, + }, + }); - 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}` }, - }); - } + 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 ` @@ -279,6 +332,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 index 7a0a860..74cdd08 100644 --- a/src/typings/message.ts +++ b/src/typings/message.ts @@ -1,18 +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', + 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; -}; \ No newline at end of file + command: (typeof MessageCommand)[keyof typeof MessageCommand]; + [key: string]: string | number | boolean | object | null | undefined; +}; From a9a1b9428ef857811ebd07921db7195830c1b761 Mon Sep 17 00:00:00 2001 From: Riley Smith Date: Fri, 20 Mar 2026 14:15:27 -0700 Subject: [PATCH 11/11] fix double import --- src/sidebarProvider.ts | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/src/sidebarProvider.ts b/src/sidebarProvider.ts index fdeb712..935a8f7 100644 --- a/src/sidebarProvider.ts +++ b/src/sidebarProvider.ts @@ -3,7 +3,6 @@ import { getClassesHtml } from './sidebarContent'; import { ApiService } from './services/apiService'; import { AuthService } from './services/authService'; import { GitService } from './services/gitService'; -import type { TestingService } from './services/testingService'; import { Gradable } from './interfaces/Gradables'; import { TestingService } from './services/testingService'; import { MessageCommand } from './typings/message'; @@ -295,13 +294,15 @@ export class SidebarProvider implements vscode.WebviewViewProvider { }, }); - this.testingService?.runGradeableWithResult( - term, - courseId, - gradeableId, - gradeableId, - gradeDetails - ); + 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}`);