diff --git a/README.md b/README.md index b43b539d..9588bf0c 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,8 @@ GitHub Action for automatically syncing LeetCode submissions to a GitHub reposit leetcode-csrf-token: ${{ secrets.LEETCODE_CSRF_TOKEN }} leetcode-session: ${{ secrets.LEETCODE_SESSION }} destination-folder: my-folder + verbose: true + commit-header: '[LeetCode Sync]' ``` 6. After you've submitted a LeetCode solution, run the workflow by going to the `Actions` tab, clicking the action name, e.g. `Sync Leetcode`, and then clicking `Run workflow`. The workflow will also automatically run once a week by default (can be configured via the `cron` parameter). @@ -64,17 +66,19 @@ GitHub Action for automatically syncing LeetCode submissions to a GitHub reposit - `leetcode-csrf-token` _(required)_: The LeetCode CSRF token for retrieving submissions from LeetCode - `leetcode-session` _(required)_: The LeetCode session value for retrieving submissions from LeetCode - `filter-duplicate-secs`: Number of seconds after an accepted solution to ignore other accepted solutions for the same problem, default: 86400 (1 day) -- `destination-folder` _(optional)_: The folder in your repo to save the submissions to (necessary for shared repos) +- `destination-folder` _(optional)_: The folder in your repo to save the submissions to (necessary for shared repos), default: _none_ +- `verbose` _(optional)_: Adds submission percentiles and question numbers to the repo (requires an additional API call), default: true +- `commit-header` _(optional)_: How the automated commits should be prefixed, default: 'Sync LeetCode submission' ## Shared Repos -A single repo can be shared by multiple users by using the `destination-folder` input field to sync each user's files to a separate folder. This is useful for users who want to add a more social, collaborative, or competitive aspect to their LeetCode sync repo. +Problems can be routed to a specific folder within a single repo using the `destination-folder` input field. This is useful for users who want to share a repo to add a more social, collaborative, or competitive aspect to their LeetCode sync repo. ## Contributing #### Testing locally -If you want to test changes to the action locally without having to commit and run the workflow on GitHub, you can edit `src/test_config.js` to have the required config values and then run: +If you want to test changes to the action locally without having to commit and run the workflow on GitHub, you can edit `src/test_config.js` to have the required config values and then run: `$ node index.js test` @@ -82,7 +86,7 @@ If you're using Replit, you can also just use the `Run` button, which is already #### Adding a new workflow parameter -If you add a workflow parameter, please make sure to also add it in `src/test_config.js`, so that it can be tested locally. +If you add a workflow parameter, please make sure to also add it in `src/test_config.js`, so that it can be tested locally. You will need to manually run: diff --git a/action.yml b/action.yml index 01625c86..ffece640 100644 --- a/action.yml +++ b/action.yml @@ -1,6 +1,6 @@ name: 'LeetCode Sync' description: 'Sync LeetCode submissions to GitHub' -branding: +branding: icon: git-commit color: yellow inputs: @@ -20,6 +20,15 @@ inputs: destination-folder: description: 'The folder to save the synced files in (relative to the top level of your repo)' required: false + default: null + verbose: + description: 'Adds submission percentiles and question numbers to the repo (requires an additional API call)' + required: false + default: true + commit-header: + description: 'How the automated commits should be prefixed' + required: false + default: 'Sync LeetCode submission' runs: using: 'node16' - main: 'index.js' \ No newline at end of file + main: 'index.js' diff --git a/index.js b/index.js index b9d334fe..c9f47a3e 100644 --- a/index.js +++ b/index.js @@ -18,6 +18,8 @@ async function main() { leetcodeSession = config.LEETCODE_SESSION; filterDuplicateSecs = config.FILTER_DUPLICATE_SECS; destinationFolder = config.DESTINATION_FOLDER; + verbose = config.VERBOSE.toString(); // Convert to string to match core.getInput('verbose') return type + commitHeader = config.COMMIT_HEADER; } else { githubToken = core.getInput('github-token'); owner = context.repo.owner; @@ -26,9 +28,11 @@ async function main() { leetcodeSession = core.getInput('leetcode-session'); filterDuplicateSecs = core.getInput('filter-duplicate-secs'); destinationFolder = core.getInput('destination-folder'); + verbose = core.getInput('verbose'); + commitHeader = core.getInput('commit-header'); } - await action.sync({ githubToken, owner, repo, filterDuplicateSecs, leetcodeCSRFToken, leetcodeSession, destinationFolder }); + await action.sync({ githubToken, owner, repo, leetcodeCSRFToken, leetcodeSession, filterDuplicateSecs, destinationFolder, verbose, commitHeader }); } main().catch((error) => { diff --git a/src/action.js b/src/action.js index 2e3edaaa..523aff9f 100644 --- a/src/action.js +++ b/src/action.js @@ -33,8 +33,63 @@ function log(message) { console.log(`[${new Date().toUTCString()}] ${message}`); } +function pad(n) { + if (n.length > 4) { + return n; + } + var s = '000' + n; + return s.substring(s.length-4); +} + function normalizeName(problemName) { - return problemName.toLowerCase().replace(/\s/g, '_'); + return problemName.toLowerCase().replace(/\s/g, '-'); +} + +async function getInfo(submission, session, csrf) { + let data = JSON.stringify({ + query: `query submissionDetails($submissionId: Int!) { + submissionDetails(submissionId: $submissionId) { + runtimePercentile + memoryPercentile + question { + questionId + } + } + }`, + variables: {'submissionId':submission.id} + }); + + let config = { + maxBodyLength: Infinity, + headers: { + 'Content-Type': 'application/json', + 'Cookie': `LEETCODE_SESSION=${session};csrftoken=${csrf};`, + }, + data : data + }; + + // No need to break on first request error since that would be done when getting submissions + const getInfo = async (maxRetries = 5, retryCount = 0) => { + try { + const response = await axios.post('https://leetcode.com/graphql/', data, config); + const runtimePercentile = `${response.data.data.submissionDetails.runtimePercentile.toFixed(2)}%`; + const memoryPercentile = `${response.data.data.submissionDetails.memoryPercentile.toFixed(2)}%`; + const questionId = pad(response.data.data.submissionDetails.question.questionId.toString()); + + log(`Got info for submission #${submission.id}`); + return { runtimePerc: runtimePercentile, memoryPerc: memoryPercentile, qid: questionId }; + } catch (exception) { + if (retryCount >= maxRetries) { + throw exception; + } + log('Error fetching submission info, retrying in ' + 3 ** retryCount + ' seconds...'); + await delay(3 ** retryCount * 1000); + return getInfo(maxRetries, retryCount + 1); + } + }; + + info = await getInfo(); + return {...submission, ...info}; } async function commit(params) { @@ -48,7 +103,8 @@ async function commit(params) { latestCommitSHA, submission, destinationFolder, - question_data + commitHeader, + questionData } = params; const name = normalizeName(submission.title); @@ -59,21 +115,29 @@ async function commit(params) { } const prefix = !!destinationFolder ? `${destinationFolder}/` : ''; - const questionPath = `${prefix}problems/${name}/question.md`; // Markdown file for the problem with question data - const solutionPath = `${prefix}problems/${name}/solution.${LANG_TO_EXTENSION[submission.lang]}`; // Separate file for the solution + const commitName = !!commitHeader ? commitHeader : COMMIT_MESSAGE; + if ('runtimePerc' in submission) { + message = `${commitName} Runtime - ${submission.runtime} (${submission.runtimePerc}), Memory - ${submission.memory} (${submission.memoryPerc})`; + qid = `${submission.qid}-`; + } else { + message = `${commitName} Runtime - ${submission.runtime}, Memory - ${submission.memory}`; + qid = ''; + } + const questionPath = `${prefix}${qid}${name}/README.md`; // Markdown file for the problem with question data + const solutionPath = `${prefix}${qid}${name}/solution.${LANG_TO_EXTENSION[submission.lang]}`; // Separate file for the solution const treeData = [ { path: questionPath, mode: '100644', - content: question_data, + content: questionData, }, { path: solutionPath, mode: '100644', - content: submission.code, - }, + content: `${submission.code}\n`, // Adds newline at EOF to conform to git recommendations + } ]; const treeResponse = await octokit.git.createTree({ @@ -87,7 +151,7 @@ async function commit(params) { const commitResponse = await octokit.git.createCommit({ owner: owner, repo: repo, - message: `${COMMIT_MESSAGE} - ${submission.title} (${submission.lang})`, + message: message, tree: treeResponse.data.sha, parents: [latestCommitSHA], author: { @@ -179,10 +243,12 @@ async function sync(inputs) { githubToken, owner, repo, - filterDuplicateSecs, leetcodeCSRFToken, leetcodeSession, - destinationFolder + filterDuplicateSecs, + destinationFolder, + verbose, + commitHeader } = inputs; const octokit = new Octokit({ @@ -197,7 +263,7 @@ async function sync(inputs) { }); let lastTimestamp = 0; - // commitInfo is used to get the original name / email to use for the author / committer. + // commitInfo is used to get the original name / email to use for the author / committer. // Since we need to modify the commit time, we can't use the default settings for the // authenticated user. let commitInfo = commits.data[commits.data.length - 1].commit.author; @@ -240,7 +306,7 @@ async function sync(inputs) { throw exception; } log('Error fetching submissions, retrying in ' + 3 ** retryCount + ' seconds...'); - // There's a rate limit on LeetCode API, so wait with backoff before retrying. + // There's a rate limit on LeetCode API, so wait with backoff before retrying. await delay(3 ** retryCount * 1000); return getSubmissions(maxRetries, retryCount + 1); } @@ -249,7 +315,7 @@ async function sync(inputs) { // the tokens are configured incorrectly. const maxRetries = (response === null) ? 0 : 5; if (response !== null) { - // Add a 1 second delay before all requests after the initial request. + // Add a 1 second delay before all requests after the initial request. await delay(1000); } response = await getSubmissions(maxRetries); @@ -275,10 +341,13 @@ async function sync(inputs) { let treeSHA = commits.data[0].commit.tree.sha; for (i = submissions.length - 1; i >= 0; i--) { submission = submissions[i]; - + if (verbose != 'false') { + submission = await getInfo(submission, leetcodeSession, leetcodeCSRFToken); + } + // Get the question data for the submission. - const question_data = await getQuestionData(submission.title_slug, leetcodeSession); - [treeSHA, latestCommitSHA] = await commit({ octokit, owner, repo, defaultBranch, commitInfo, treeSHA, latestCommitSHA, submission, destinationFolder, question_data }); + const questionData = await getQuestionData(submission.title_slug, leetcodeSession); + [treeSHA, latestCommitSHA] = await commit({ octokit, owner, repo, defaultBranch, commitInfo, treeSHA, latestCommitSHA, submission, destinationFolder, commitHeader, questionData }); } log('Done syncing all submissions.'); } diff --git a/src/test_config.js b/src/test_config.js index e507d73b..5d9180dd 100644 --- a/src/test_config.js +++ b/src/test_config.js @@ -1,14 +1,15 @@ // Modify this file to run index.js locally and not as a GitHub Action. module.exports = { + // These parameters are required. GITHUB_TOKEN: null, - // Form of "/" - GITHUB_REPO: null, - + GITHUB_REPO: null, // Form of '/' LEETCODE_CSRF_TOKEN: null, LEETCODE_SESSION: null, - // These parameters are optional and have default values. + // These parameters are optional and have default values if needed. FILTER_DUPLICATE_SECS: 86400, DESTINATION_FOLDER: null, -} \ No newline at end of file + VERBOSE: true, + COMMIT_HEADER: 'Sync LeetCode submission' +}