From 44c521ac867026b18433b006649d9e1ebcb15ce4 Mon Sep 17 00:00:00 2001 From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:26:56 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20implement=20issue=20#427=20?= =?UTF-8?q?=E2=80=94=20SonarCloud:=20cognitive=20complexity=20(S3776)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/index.html | 125 +++++--- src/calendar-to-sheets/src/index.js | 186 +++++++---- src/gmail-to-drive-by-labels/src/index.js | 362 +++++++++++++--------- 3 files changed, 408 insertions(+), 265 deletions(-) diff --git a/deploy/index.html b/deploy/index.html index 323453b8..11d05edf 100644 --- a/deploy/index.html +++ b/deploy/index.html @@ -1887,33 +1887,11 @@

3Configure your scripts

const files = await fetchScriptFiles(catalogId) // ── Idempotent project lookup / creation ──────────────────────── - let projectId = null - const storedId = getStoredScriptId(catalogId, projectTitle) - if (storedId) { - showStatus( - 'info', - ` Verifying existing project ${escapeHtml(projectTitle)}${progress}…` - ) - try { - await apiFetch(`${APPS_SCRIPT_API}/projects/${storedId}`) - projectId = storedId - } catch { - // Project no longer exists — fall through to creation. - } - } - - if (!projectId) { - showStatus( - 'info', - ` Creating project ${escapeHtml(projectTitle)}${progress}…` - ) - const project = await apiFetch(`${APPS_SCRIPT_API}/projects`, { - method: 'POST', - body: JSON.stringify({ title: projectTitle }), - }) - projectId = validateScriptId(project.scriptId) - storeScriptId(catalogId, projectTitle, projectId) - } + const projectId = await resolveDeployProjectId( + catalogId, + projectTitle, + progress + ) showStatus( 'info', @@ -1986,30 +1964,7 @@

3Configure your scripts

// Show Step 3 config cards renderConfigCards(deployedProjects) } catch (err) { - const isApiDisabled = - err.message && err.message.includes('Apps Script API') - const errorDetail = err.stack || err.message || String(err) - const errorDetailBlock = - `
` + - `Error detail` + - `
${escapeHtml(errorDetail)}
` + - `` + - `
` - const errorHtml = isApiDisabled - ? '❌ Deployment failed: User has not enabled the Apps Script API.' + - '' + - '👉 Enable it at script.google.com/home/usersettings' + - '' + - '' + - 'After enabling, wait a minute then try again.' + - '' + - errorDetailBlock - : `❌ Deployment failed: ${escapeHtml(err.message)}` + - errorDetailBlock - showStatus('error', errorHtml) + showStatus('error', buildDeployErrorHtml(err)) } finally { btn.disabled = false btn.innerHTML = 'Deploy to my account' @@ -2019,6 +1974,74 @@

3Configure your scripts

// ── Helpers ─────────────────────────────────────────────────────────────────── + /** + * Resolves the Apps Script project id for a catalog entry idempotently: + * reuses a stored project when it still exists, otherwise creates a new one + * and remembers its id. + */ + async function resolveDeployProjectId(catalogId, projectTitle, progress) { + const storedId = getStoredScriptId(catalogId, projectTitle) + if (storedId) { + showStatus( + 'info', + ` Verifying existing project ${escapeHtml(projectTitle)}${progress}…` + ) + try { + await apiFetch(`${APPS_SCRIPT_API}/projects/${storedId}`) + return storedId + } catch { + // Project no longer exists — fall through to creation. + } + } + + showStatus( + 'info', + ` Creating project ${escapeHtml(projectTitle)}${progress}…` + ) + const project = await apiFetch(`${APPS_SCRIPT_API}/projects`, { + method: 'POST', + body: JSON.stringify({ title: projectTitle }), + }) + const projectId = validateScriptId(project.scriptId) + storeScriptId(catalogId, projectTitle, projectId) + return projectId + } + + /** + * Builds the status HTML shown when a deployment fails, with a special + * hint when the failure is a disabled Apps Script API, plus a collapsible + * error-detail block with a copy button. + */ + function buildDeployErrorHtml(err) { + const isApiDisabled = + err.message && err.message.includes('Apps Script API') + const errorDetail = err.stack || err.message || String(err) + const errorDetailBlock = + `
` + + `Error detail` + + `
${escapeHtml(errorDetail)}
` + + `` + + `
` + if (isApiDisabled) { + return ( + '❌ Deployment failed: User has not enabled the Apps Script API.' + + '' + + '👉 Enable it at script.google.com/home/usersettings' + + '' + + '' + + 'After enabling, wait a minute then try again.' + + '' + + errorDetailBlock + ) + } + return ( + `❌ Deployment failed: ${escapeHtml(err.message)}` + errorDetailBlock + ) + } + /** * Fetches the code.gs and config.gs source files for `scriptId` from GitHub. * Returns an array of `{ name, source }` objects ready for the API. diff --git a/src/calendar-to-sheets/src/index.js b/src/calendar-to-sheets/src/index.js index 1eb60e56..c9fc9c88 100644 --- a/src/calendar-to-sheets/src/index.js +++ b/src/calendar-to-sheets/src/index.js @@ -106,6 +106,116 @@ function ensureHeader(sheet) { } } +/** + * Applies desired rows against the sheet: updates changed existing rows and + * collects brand-new rows to insert. + * + * @param {Object} sheet - Sheet object + * @param {Map} desiredMap - Map of id -> desired row + * @param {Map} existingMap - Map of id -> { rowIndex, values } + * @returns {{updateCount: number, rowsToInsert: Array}} + */ +function upsertRows(sheet, desiredMap, existingMap) { + let updateCount = 0 + const rowsToInsert = [] + + for (const [id, row] of desiredMap.entries()) { + const ex = existingMap.get(id) + if (!ex) { + rowsToInsert.push(row) + continue + } + if (!rowsEqual(row, ex.values)) { + console.log('[syncCalendarToSheet] Updating row for event:', id) + sheet.getRange(ex.rowIndex, 1, 1, row.length).setValues([row]) + updateCount++ + } + } + + return { updateCount, rowsToInsert } +} + +/** + * Appends new rows to the sheet, using a single batched range write when the + * sheet supports getLastRow(), otherwise falling back to appendRow(). + * + * @param {Object} sheet - Sheet object + * @param {Array} rowsToInsert - Rows to append + */ +function insertNewRows(sheet, rowsToInsert) { + if (rowsToInsert.length === 0) return + + console.log( + '[syncCalendarToSheet] Inserting new events:', + rowsToInsert.length + ) + if (typeof sheet.getLastRow === 'function') { + sheet + .getRange( + sheet.getLastRow() + 1, + 1, + rowsToInsert.length, + rowsToInsert[0].length + ) + .setValues(rowsToInsert) + } else { + rowsToInsert.forEach((row) => sheet.appendRow(row)) + } +} + +/** + * Decides whether an existing row (for an event no longer desired) should be + * deleted. Only rows with valid start/end columns whose event time falls within + * [start, end] are deleted; everything else is preserved as historical data. + * + * @param {string} id - Event id (for logging) + * @param {Object} ex - Existing row record { rowIndex, values } + * @param {Date} start - Sync window start + * @param {Date} end - Sync window end + * @returns {boolean} True if the row should be deleted + */ +function shouldDeleteRow(id, ex, start, end) { + const rowStart = ex.values[2] // start is at index 2 + const rowEnd = ex.values[3] // end is at index 3 + + if (!rowStart || !rowEnd) { + console.log( + '[syncCalendarToSheet] Preserving event with invalid dates:', + id + ) + return false + } + + const rowStartTime = new Date(rowStart) + if (!isNaN(rowStartTime) && rowStartTime >= start && rowStartTime <= end) { + console.log('[syncCalendarToSheet] Marking event for deletion:', id) + return true + } + + console.log('[syncCalendarToSheet] Preserving event outside sync window:', id) + return false +} + +/** + * Computes the row indexes to delete for events that no longer exist, limited to + * the synced time window. + * + * @param {Map} existingMap - Map of id -> { rowIndex, values } + * @param {Map} desiredMap - Map of id -> desired row + * @param {Date} start - Sync window start + * @param {Date} end - Sync window end + * @returns {Array} Row indexes to delete + */ +function computeRowsToDelete(existingMap, desiredMap, start, end) { + const toDelete = [] + for (const [id, ex] of existingMap.entries()) { + if (!desiredMap.has(id) && shouldDeleteRow(id, ex, start, end)) { + toDelete.push(ex.rowIndex) + } + } + return toDelete +} + async function syncCalendarToSheet( calendar, sheet, @@ -133,42 +243,12 @@ async function syncCalendarToSheet( const existingMap = rowsToMap(body) // Upsert - let updateCount = 0 - const rowsToInsert = [] - - for (const [id, row] of desiredMap.entries()) { - if (existingMap.has(id)) { - const ex = existingMap.get(id) - if (!rowsEqual(row, ex.values)) { - // update - console.log('[syncCalendarToSheet] Updating row for event:', id) - const rowIndex = ex.rowIndex - sheet.getRange(rowIndex, 1, 1, row.length).setValues([row]) - updateCount++ - } - } else { - rowsToInsert.push(row) - } - } - - if (rowsToInsert.length > 0) { - console.log( - '[syncCalendarToSheet] Inserting new events:', - rowsToInsert.length - ) - if (typeof sheet.getLastRow === 'function') { - sheet - .getRange( - sheet.getLastRow() + 1, - 1, - rowsToInsert.length, - rowsToInsert[0].length - ) - .setValues(rowsToInsert) - } else { - rowsToInsert.forEach((row) => sheet.appendRow(row)) - } - } + const { updateCount, rowsToInsert } = upsertRows( + sheet, + desiredMap, + existingMap + ) + insertNewRows(sheet, rowsToInsert) console.log( '[syncCalendarToSheet] Updates:', @@ -179,38 +259,8 @@ async function syncCalendarToSheet( // Delete rows for events that no longer exist, but only if they fall within // the synced time window to avoid deleting rows from events outside [start,end] - const toDelete = [] - for (const [id, ex] of existingMap.entries()) { - if (!desiredMap.has(id)) { - // Only delete if the row has start/end columns and falls within [start,end] - // Otherwise, preserve historical rows outside the sync window - const rowStart = ex.values[2] // start is at index 2 - const rowEnd = ex.values[3] // end is at index 3 - if (rowStart && rowEnd) { - const rowStartTime = new Date(rowStart) - // Only delete if row's event time falls within our sync window - if ( - !isNaN(rowStartTime) && - rowStartTime >= start && - rowStartTime <= end - ) { - console.log('[syncCalendarToSheet] Marking event for deletion:', id) - toDelete.push(ex.rowIndex) - } else { - console.log( - '[syncCalendarToSheet] Preserving event outside sync window:', - id - ) - } - } else { - // If no valid date columns, don't delete (preserve historical data) - console.log( - '[syncCalendarToSheet] Preserving event with invalid dates:', - id - ) - } - } - } + const toDelete = computeRowsToDelete(existingMap, desiredMap, start, end) + // delete from bottom to top console.log('[syncCalendarToSheet] Deleting rows:', toDelete.length) toDelete.sort((a, b) => b - a).forEach((r) => sheet.deleteRow(r)) diff --git a/src/gmail-to-drive-by-labels/src/index.js b/src/gmail-to-drive-by-labels/src/index.js index 37524d15..65ab63f3 100644 --- a/src/gmail-to-drive-by-labels/src/index.js +++ b/src/gmail-to-drive-by-labels/src/index.js @@ -71,6 +71,88 @@ function removeExistingThread(body, threadId) { return true } +/** + * Determine whether an attachment already exists in the folder by comparing + * size first (fast fail) and then MD5 hash against every same-named file. + * + * @param {Object} folder - Drive folder object + * @param {string} fileName - The attachment file name to look up + * @param {Object} newFileBlob - The attachment blob to compare + * @returns {boolean} True if an identical file already exists + */ +function isDuplicateAttachment(folder, fileName, newFileBlob) { + const existingFiles = folder.getFilesByName(fileName) + console.log( + '[processMessageToDoc] Checking for existing files named:', + fileName + ) + + let existingCount = 0 + while (existingFiles.hasNext()) { + existingCount++ + const existingFile = existingFiles.next() + console.log( + '[processMessageToDoc] Comparing with existing file', + existingCount + ) + + // Compare sizes first (fast fail) + // Try getBytes() for GAS blobs, bytes property for test mocks, empty buffer as fallback + const newFileBytes = newFileBlob.getBytes + ? newFileBlob.getBytes() + : newFileBlob.bytes || Buffer.from('') + if (existingFile.getSize() !== newFileBytes.length) { + console.log('[processMessageToDoc] Size mismatch - different file') + continue + } + + console.log('[processMessageToDoc] Size match, checking hash') + // Deep check: compare MD5 hashes + const existingHash = getFileHash(existingFile.getBlob()) + const newHash = getFileHash(newFileBlob) + if (existingHash === newHash) { + console.log('[processMessageToDoc] Hash match - duplicate detected') + return true + } + console.log('[processMessageToDoc] Hash mismatch - different content') + } + + return false +} + +/** + * Resolve a non-conflicting file name for a new attachment. When a same-named + * file already exists (with different content), a timestamp is inserted before + * the extension and the blob is renamed in place. + * + * @param {Object} folder - Drive folder object + * @param {string} fileName - The desired file name + * @param {Object} newFileBlob - The attachment blob (renamed in place if needed) + * @param {Object} options - Settings (Utilities, Session for GAS) + * @returns {string} The resolved file name + */ +function resolveAttachmentFileName(folder, fileName, newFileBlob, options) { + const { Utilities, Session } = options + + if (!folder.getFilesByName(fileName).hasNext()) { + return fileName + } + + console.log('[processMessageToDoc] Name conflict detected, adding timestamp') + const timeTag = + Utilities && Session + ? Utilities.formatDate(new Date(), Session.getScriptTimeZone(), '_HHmmss') + : '_' + Date.now() + const newName = fileName.replace(/(\.[\w\d_-]+)$/i, timeTag + '$1') + const resolved = newName === fileName ? fileName + timeTag : newName + + if (newFileBlob.setName) { + newFileBlob.setName(resolved) + } + console.log('[processMessageToDoc] Renamed to:', resolved) + return resolved +} + /** * Process a single message and prepend its content to the document body. * Returns the number of paragraphs inserted. @@ -135,51 +217,8 @@ function processMessageToDoc(message, body, folder, options = {}) { let fileName = att.getName() // In GAS environment, copyBlob() creates a copy; in test environment, att itself is the blob const newFileBlob = att.copyBlob ? att.copyBlob() : att - let isDuplicate = false - - // Check for duplicates by content hash - const existingFiles = folder.getFilesByName(fileName) - console.log( - '[processMessageToDoc] Checking for existing files named:', - fileName - ) - - let existingCount = 0 - while (existingFiles.hasNext()) { - existingCount++ - const existingFile = existingFiles.next() - console.log( - '[processMessageToDoc] Comparing with existing file', - existingCount - ) - - // Compare sizes first (fast fail) - // Try getBytes() for GAS blobs, bytes property for test mocks, empty buffer as fallback - const newFileBytes = newFileBlob.getBytes - ? newFileBlob.getBytes() - : newFileBlob.bytes || Buffer.from('') - if (existingFile.getSize() === newFileBytes.length) { - console.log('[processMessageToDoc] Size match, checking hash') - - // Deep check: compare MD5 hashes - const existingHash = getFileHash(existingFile.getBlob()) - const newHash = getFileHash(newFileBlob) - - if (existingHash === newHash) { - console.log('[processMessageToDoc] Hash match - duplicate detected') - isDuplicate = true - break - } else { - console.log( - '[processMessageToDoc] Hash mismatch - different content' - ) - } - } else { - console.log('[processMessageToDoc] Size mismatch - different file') - } - } - if (isDuplicate) { + if (isDuplicateAttachment(folder, fileName, newFileBlob)) { if (Logger) { Logger.log('Skipping exact duplicate: ' + fileName) } @@ -190,32 +229,12 @@ function processMessageToDoc(message, body, folder, options = {}) { ) } else { // Handle name conflicts (same name, different content) - if (folder.getFilesByName(fileName).hasNext()) { - console.log( - '[processMessageToDoc] Name conflict detected, adding timestamp' - ) - - if (Utilities && Session) { - const timeTag = Utilities.formatDate( - new Date(), - Session.getScriptTimeZone(), - '_HHmmss' - ) - const newName = fileName.replace(/(\.[\w\d_-]+)$/i, timeTag + '$1') - fileName = newName === fileName ? fileName + timeTag : newName - } else { - // Test environment - simple timestamp - const timeTag = '_' + Date.now() - const newName = fileName.replace(/(\.[\w\d_-]+)$/i, timeTag + '$1') - fileName = newName === fileName ? fileName + timeTag : newName - } - - if (newFileBlob.setName) { - newFileBlob.setName(fileName) - } - console.log('[processMessageToDoc] Renamed to:', fileName) - } - + fileName = resolveAttachmentFileName( + folder, + fileName, + newFileBlob, + options + ) console.log('[processMessageToDoc] Saving new file:', fileName) const file = folder.createFile(newFileBlob) body.insertParagraph(currentIndex++, '- ' + file.getName()) @@ -679,6 +698,113 @@ function rebuildAllDocs(configs, rebuildDocFn) { return completed } +/** + * Runs the "clear_doc" phase of a rebuild: clears the destination document and + * advances the state to "move_emails". No-op when the state is in a later phase. + * + * @param {Object} state - Mutable rebuild state ({ phase, processedCount }) + * @param {Object} config - Configuration object + * @param {Object} DocumentApp - GAS DocumentApp service + * @param {Object} properties - GAS user properties store + * @param {string} stateKey - Key under which the state is persisted + * @returns {(boolean|null)} A boolean result rebuildDoc should return, or null to continue + */ +function clearDocPhase(state, config, DocumentApp, properties, stateKey) { + if (state.phase !== 'clear_doc') return null + + console.log('[rebuildDoc] Clearing document:', config.docId) + try { + const doc = DocumentApp.openById(config.docId) + const body = doc.getBody() + + // Clear all content from the document body in a single operation + body.setText('') + console.log('[rebuildDoc] Document cleared') + + // Move to next phase + state.phase = 'move_emails' + state.processedCount = 0 + properties.setProperty(stateKey, JSON.stringify(state)) + console.log('[rebuildDoc] Saved state, moving to email processing phase') + return null + } catch (e) { + console.error('[rebuildDoc] Error clearing document:', e.message) + // Try again next time + return false + } +} + +/** + * Runs the "move_emails" phase of a rebuild: moves threads from the processed + * label back to the trigger label in a time-bounded batch. No-op unless the + * state is "move_emails" and a processed label exists. + * + * @param {Object} state - Mutable rebuild state ({ phase, processedCount }) + * @param {{triggerLabel: Object, processedLabel: Object}} labels - Gmail labels + * @param {Object} properties - GAS user properties store + * @param {string} stateKey - Key under which the state is persisted + * @param {{BATCH_SIZE: number, MAX_EXECUTION_TIME: number, startTime: number}} limits + * @returns {(boolean|null)} A boolean result rebuildDoc should return, or null to continue + */ +function moveEmailsPhase(state, labels, properties, stateKey, limits) { + const { triggerLabel, processedLabel } = labels + if (state.phase !== 'move_emails' || !processedLabel) return null + + const { BATCH_SIZE, MAX_EXECUTION_TIME, startTime } = limits + + console.log('[rebuildDoc] Moving emails from processed to trigger label') + console.log( + '[rebuildDoc] Resuming from:', + state.processedCount, + 'threads processed' + ) + + const threads = processedLabel.getThreads() + console.log('[rebuildDoc] Found', threads.length, 'threads to move') + + // Process in batches, always from index 0 since we're removing items + let batchCount = 0 + while (batchCount < BATCH_SIZE && threads.length > 0) { + // Check if we're running out of time + const elapsed = new Date().getTime() - startTime + if (elapsed > MAX_EXECUTION_TIME) { + console.log('[rebuildDoc] Approaching time limit, saving progress') + state.processedCount += batchCount + properties.setProperty(stateKey, JSON.stringify(state)) + return false // Not complete, run again + } + + // Always process index 0 since removing items shrinks the array + const thread = threads[0] + processedLabel.removeFromThread(thread) + triggerLabel.addToThread(thread) + batchCount++ + + // Refresh threads array + threads.splice(0, 1) + } + + console.log('[rebuildDoc] Moved', batchCount, 'threads in this batch') + state.processedCount += batchCount + + // Check if we're done + if (processedLabel.getThreads().length === 0) { + console.log('[rebuildDoc] All threads moved') + state.phase = 'complete' + properties.setProperty(stateKey, JSON.stringify(state)) + return null + } + + // Still more threads to process + console.log( + '[rebuildDoc] Still', + processedLabel.getThreads().length, + 'threads remaining' + ) + properties.setProperty(stateKey, JSON.stringify(state)) + return false // Not complete, need to run again +} + /** * Rebuilds a single document by clearing it and moving processed emails back to trigger label. * Uses batching and state tracking to handle large label sets without timing out. @@ -718,86 +844,30 @@ function rebuildDoc(config, services) { ) } - // 2. Check if we need to clear the document (only on first run) + // 2. Load persisted state (first run starts in the clear_doc phase) const properties = PropertiesService.getUserProperties() const rebuildState = properties.getProperty(stateKey) const state = rebuildState ? JSON.parse(rebuildState) : { phase: 'clear_doc' } - if (state.phase === 'clear_doc') { - console.log('[rebuildDoc] Clearing document:', config.docId) - try { - const doc = DocumentApp.openById(config.docId) - const body = doc.getBody() - - // Clear all content from the document body in a single operation - body.setText('') - console.log('[rebuildDoc] Document cleared') - - // Move to next phase - state.phase = 'move_emails' - state.processedCount = 0 - properties.setProperty(stateKey, JSON.stringify(state)) - console.log('[rebuildDoc] Saved state, moving to email processing phase') - } catch (e) { - console.error('[rebuildDoc] Error clearing document:', e.message) - // Try again next time - return false - } - } - - // 3. Move emails from processed back to trigger label (batched) - if (state.phase === 'move_emails' && processedLabel) { - console.log('[rebuildDoc] Moving emails from processed to trigger label') - console.log( - '[rebuildDoc] Resuming from:', - state.processedCount, - 'threads processed' - ) - - const threads = processedLabel.getThreads() - console.log('[rebuildDoc] Found', threads.length, 'threads to move') - - // Process in batches, always from index 0 since we're removing items - let batchCount = 0 - while (batchCount < BATCH_SIZE && threads.length > 0) { - // Check if we're running out of time - const elapsed = new Date().getTime() - startTime - if (elapsed > MAX_EXECUTION_TIME) { - console.log('[rebuildDoc] Approaching time limit, saving progress') - state.processedCount += batchCount - properties.setProperty(stateKey, JSON.stringify(state)) - return false // Not complete, run again - } - - // Always process index 0 since removing items shrinks the array - const thread = threads[0] - processedLabel.removeFromThread(thread) - triggerLabel.addToThread(thread) - batchCount++ - - // Refresh threads array - threads.splice(0, 1) - } - - console.log('[rebuildDoc] Moved', batchCount, 'threads in this batch') - state.processedCount += batchCount - - // Check if we're done - if (processedLabel.getThreads().length === 0) { - console.log('[rebuildDoc] All threads moved') - state.phase = 'complete' - properties.setProperty(stateKey, JSON.stringify(state)) - } else { - // Still more threads to process - console.log( - '[rebuildDoc] Still', - processedLabel.getThreads().length, - 'threads remaining' - ) - properties.setProperty(stateKey, JSON.stringify(state)) - return false // Not complete, need to run again - } - } + // 3. Clear the document (only on first run) + const clearResult = clearDocPhase( + state, + config, + DocumentApp, + properties, + stateKey + ) + if (clearResult !== null) return clearResult + + // 4. Move emails from processed back to trigger label (batched) + const moveResult = moveEmailsPhase( + state, + { triggerLabel, processedLabel }, + properties, + stateKey, + { BATCH_SIZE, MAX_EXECUTION_TIME, startTime } + ) + if (moveResult !== null) return moveResult // If we got here, we're done properties.deleteProperty(stateKey) From c1afe93d7b424855ca0faa07c0d66db9f199558e Mon Sep 17 00:00:00 2001 From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:32:46 +0000 Subject: [PATCH 2/3] fix(reviews): address review comments [skip ci-relay] --- deploy/index.html | 10 ++++------ src/gmail-to-drive-by-labels/src/index.js | 14 +++++--------- 2 files changed, 9 insertions(+), 15 deletions(-) diff --git a/deploy/index.html b/deploy/index.html index 11d05edf..4ccbb903 100644 --- a/deploy/index.html +++ b/deploy/index.html @@ -2013,9 +2013,9 @@

3Configure your scripts

* error-detail block with a copy button. */ function buildDeployErrorHtml(err) { - const isApiDisabled = - err.message && err.message.includes('Apps Script API') - const errorDetail = err.stack || err.message || String(err) + const errMsg = err && err.message ? err.message : String(err || '') + const isApiDisabled = errMsg.includes('Apps Script API') + const errorDetail = (err && err.stack) || errMsg const errorDetailBlock = `
` + `Error detail` + @@ -2037,9 +2037,7 @@

3Configure your scripts

errorDetailBlock ) } - return ( - `❌ Deployment failed: ${escapeHtml(err.message)}` + errorDetailBlock - ) + return `❌ Deployment failed: ${escapeHtml(errMsg)}` + errorDetailBlock } /** diff --git a/src/gmail-to-drive-by-labels/src/index.js b/src/gmail-to-drive-by-labels/src/index.js index 65ab63f3..44f6fa14 100644 --- a/src/gmail-to-drive-by-labels/src/index.js +++ b/src/gmail-to-drive-by-labels/src/index.js @@ -762,9 +762,9 @@ function moveEmailsPhase(state, labels, properties, stateKey, limits) { const threads = processedLabel.getThreads() console.log('[rebuildDoc] Found', threads.length, 'threads to move') - // Process in batches, always from index 0 since we're removing items + // Process in batches let batchCount = 0 - while (batchCount < BATCH_SIZE && threads.length > 0) { + while (batchCount < BATCH_SIZE && batchCount < threads.length) { // Check if we're running out of time const elapsed = new Date().getTime() - startTime if (elapsed > MAX_EXECUTION_TIME) { @@ -774,21 +774,17 @@ function moveEmailsPhase(state, labels, properties, stateKey, limits) { return false // Not complete, run again } - // Always process index 0 since removing items shrinks the array - const thread = threads[0] + const thread = threads[batchCount] processedLabel.removeFromThread(thread) triggerLabel.addToThread(thread) batchCount++ - - // Refresh threads array - threads.splice(0, 1) } console.log('[rebuildDoc] Moved', batchCount, 'threads in this batch') state.processedCount += batchCount // Check if we're done - if (processedLabel.getThreads().length === 0) { + if (batchCount === threads.length) { console.log('[rebuildDoc] All threads moved') state.phase = 'complete' properties.setProperty(stateKey, JSON.stringify(state)) @@ -798,7 +794,7 @@ function moveEmailsPhase(state, labels, properties, stateKey, limits) { // Still more threads to process console.log( '[rebuildDoc] Still', - processedLabel.getThreads().length, + threads.length - batchCount, 'threads remaining' ) properties.setProperty(stateKey, JSON.stringify(state)) From 31f26327c271848ead58edd1cecaa25b138c099a Mon Sep 17 00:00:00 2001 From: donpetry-bot <281750570+donpetry-bot@users.noreply.github.com> Date: Tue, 7 Jul 2026 19:36:00 +0000 Subject: [PATCH 3/3] chore: apply manual instructions [skip ci-relay] --- .github/skills/bmad-retrospective/workflow.md | 1 + .github/skills/bmad-sprint-status/workflow.md | 1 + .../bmad-retrospective/workflow.md | 1 + .../bmad-sprint-status/workflow.md | 1 + deploy/index.html | 5 +- deploy/tests/ui.spec.js | 57 +++++++++++++++++++ 6 files changed, 64 insertions(+), 2 deletions(-) diff --git a/.github/skills/bmad-retrospective/workflow.md b/.github/skills/bmad-retrospective/workflow.md index 5b1c4637..f810f95d 100644 --- a/.github/skills/bmad-retrospective/workflow.md +++ b/.github/skills/bmad-retrospective/workflow.md @@ -449,6 +449,7 @@ Alice (Product Owner): "Good thinking - helps us connect what we learned to what - Deployment or environment setup + Bob (Scrum Master): "Alright, I've reviewed Epic {{next_epic_num}}: '{{next_epic_title}}'" Alice (Product Owner): "What are we looking at?" diff --git a/.github/skills/bmad-sprint-status/workflow.md b/.github/skills/bmad-sprint-status/workflow.md index 652a0996..447545aa 100644 --- a/.github/skills/bmad-sprint-status/workflow.md +++ b/.github/skills/bmad-sprint-status/workflow.md @@ -86,6 +86,7 @@ Run `/bmad:bmm:workflows:sprint-planning` to generate it, then rerun sprint-stat + ⚠️ **Unknown status detected:** {{#each invalid_entries}} diff --git a/_bmad/bmm/workflows/4-implementation/bmad-retrospective/workflow.md b/_bmad/bmm/workflows/4-implementation/bmad-retrospective/workflow.md index 5b1c4637..f810f95d 100644 --- a/_bmad/bmm/workflows/4-implementation/bmad-retrospective/workflow.md +++ b/_bmad/bmm/workflows/4-implementation/bmad-retrospective/workflow.md @@ -449,6 +449,7 @@ Alice (Product Owner): "Good thinking - helps us connect what we learned to what - Deployment or environment setup + Bob (Scrum Master): "Alright, I've reviewed Epic {{next_epic_num}}: '{{next_epic_title}}'" Alice (Product Owner): "What are we looking at?" diff --git a/_bmad/bmm/workflows/4-implementation/bmad-sprint-status/workflow.md b/_bmad/bmm/workflows/4-implementation/bmad-sprint-status/workflow.md index 652a0996..447545aa 100644 --- a/_bmad/bmm/workflows/4-implementation/bmad-sprint-status/workflow.md +++ b/_bmad/bmm/workflows/4-implementation/bmad-sprint-status/workflow.md @@ -86,6 +86,7 @@ Run `/bmad:bmm:workflows:sprint-planning` to generate it, then rerun sprint-stat + ⚠️ **Unknown status detected:** {{#each invalid_entries}} diff --git a/deploy/index.html b/deploy/index.html index 4ccbb903..8e20b000 100644 --- a/deploy/index.html +++ b/deploy/index.html @@ -1989,8 +1989,9 @@

3Configure your scripts

try { await apiFetch(`${APPS_SCRIPT_API}/projects/${storedId}`) return storedId - } catch { - // Project no longer exists — fall through to creation. + } catch (err) { + if (err.httpStatus !== 404) throw err + // 404: project no longer exists — fall through to creation. } } diff --git a/deploy/tests/ui.spec.js b/deploy/tests/ui.spec.js index c627953a..d32ab620 100644 --- a/deploy/tests/ui.spec.js +++ b/deploy/tests/ui.spec.js @@ -1041,6 +1041,63 @@ test.describe('deploy index.html', () => { ) }) + test('handleDeploy propagates non-404 error from project verification and does not create a new project', async ({ + page, + }) => { + await page.evaluate(() => { + localStorage.setItem( + 'gas_copilot_deployed', + JSON.stringify({ + 'gmail-to-drive-by-labels\nPetry-Projects – Gmail to Drive By Labels': + 'auth-error-script-id', + }) + ) + }) + + let projectCreated = false + + await page.route('https://raw.githubusercontent.com/**', async (route) => { + await route.fulfill({ status: 200, body: '// code' }) + }) + await page.route('https://script.googleapis.com/**', async (route) => { + const url = route.request().url() + const method = route.request().method() + if (method === 'GET' && url.includes('/projects/auth-error-script-id')) { + // Simulate a transient auth failure (not 404) + await route.fulfill({ + status: 403, + contentType: 'application/json', + body: JSON.stringify({ + error: { message: 'The caller does not have permission' }, + }), + }) + } else if (method === 'POST' && url.endsWith('/projects')) { + projectCreated = true + await route.fulfill({ + status: 200, + body: JSON.stringify({ scriptId: 'should-not-be-created' }), + }) + } else if (method === 'PUT' && url.includes('/content')) { + await route.fulfill({ status: 200, body: '{}' }) + } else { + await route.continue() + } + }) + + await signIn(page) + await page + .locator('#script-list input[value="gmail-to-drive-by-labels"]') + .click() + await page.locator('#btn-deploy').click() + await page.waitForSelector('.status-error') + + // A 403 during verification must surface as a failure, not silently fall through + expect(projectCreated).toBe(false) + await expect(page.locator('.status-error')).toContainText( + 'Deployment failed' + ) + }) + test('handleDeploy creates new project if stored project was deleted', async ({ page, }) => {