Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/skills/bmad-retrospective/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ Alice (Product Owner): "Good thinking - helps us connect what we learned to what
- Deployment or environment setup

<output>

Bob (Scrum Master): "Alright, I've reviewed Epic {{next_epic_num}}: '{{next_epic_title}}'"

Alice (Product Owner): "What are we looking at?"
Expand Down
1 change: 1 addition & 0 deletions .github/skills/bmad-sprint-status/workflow.md
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Run `/bmad:bmm:workflows:sprint-planning` to generate it, then rerun sprint-stat

<check if="any status is unrecognized">
<output>

⚠️ **Unknown status detected:**
{{#each invalid_entries}}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -449,6 +449,7 @@ Alice (Product Owner): "Good thinking - helps us connect what we learned to what
- Deployment or environment setup

<output>

Bob (Scrum Master): "Alright, I've reviewed Epic {{next_epic_num}}: '{{next_epic_title}}'"

Alice (Product Owner): "What are we looking at?"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,6 +86,7 @@ Run `/bmad:bmm:workflows:sprint-planning` to generate it, then rerun sprint-stat

<check if="any status is unrecognized">
<output>

⚠️ **Unknown status detected:**
{{#each invalid_entries}}

Expand Down
124 changes: 73 additions & 51 deletions deploy/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -1887,33 +1887,11 @@ <h2><span class="step-badge">3</span>Configure your scripts</h2>
const files = await fetchScriptFiles(catalogId)

// ── Idempotent project lookup / creation ────────────────────────
let projectId = null
const storedId = getStoredScriptId(catalogId, projectTitle)
if (storedId) {
showStatus(
'info',
`<span class="spinner" style="border-color:rgba(26,115,232,.3);border-top-color:#1a73e8;"></span> Verifying existing project <strong>${escapeHtml(projectTitle)}</strong>${progress}…`
)
try {
await apiFetch(`${APPS_SCRIPT_API}/projects/${storedId}`)
projectId = storedId
} catch {
// Project no longer exists — fall through to creation.
}
}

if (!projectId) {
showStatus(
'info',
`<span class="spinner" style="border-color:rgba(26,115,232,.3);border-top-color:#1a73e8;"></span> Creating project <strong>${escapeHtml(projectTitle)}</strong>${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',
Expand Down Expand Up @@ -1986,30 +1964,7 @@ <h2><span class="step-badge">3</span>Configure your scripts</h2>
// 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 =
`<details style="margin-top:6px;font-size:12px;">` +
`<summary style="cursor:pointer;color:#888;">Error detail</summary>` +
`<div class="error-detail">${escapeHtml(errorDetail)}</div>` +
`<button class="copy-btn" data-copy="${encodeURIComponent(errorDetail)}"` +
` onclick="navigator.clipboard.writeText(decodeURIComponent(this.dataset.copy)).catch(()=>{})">` +
`📋 Copy error</button>` +
`</details>`
const errorHtml = isApiDisabled
? '❌ Deployment failed: User has not enabled the Apps Script API.' +
'<a href="https://script.google.com/home/usersettings" target="_blank" rel="noopener"' +
' style="display:block;margin-top:8px;">' +
'👉 Enable it at script.google.com/home/usersettings' +
'</a>' +
'<span style="font-size:12px;color:#888;display:block;margin-top:4px;">' +
'After enabling, wait a minute then try again.' +
'</span>' +
errorDetailBlock
: `❌ Deployment failed: ${escapeHtml(err.message)}` +
errorDetailBlock
showStatus('error', errorHtml)
showStatus('error', buildDeployErrorHtml(err))
} finally {
btn.disabled = false
btn.innerHTML = 'Deploy to my account'
Expand All @@ -2019,6 +1974,73 @@ <h2><span class="step-badge">3</span>Configure your scripts</h2>

// ── 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',
`<span class="spinner" style="border-color:rgba(26,115,232,.3);border-top-color:#1a73e8;"></span> Verifying existing project <strong>${escapeHtml(projectTitle)}</strong>${progress}…`
)
try {
await apiFetch(`${APPS_SCRIPT_API}/projects/${storedId}`)
return storedId
} catch (err) {
if (err.httpStatus !== 404) throw err
// 404: project no longer exists — fall through to creation.
}
}

showStatus(
'info',
`<span class="spinner" style="border-color:rgba(26,115,232,.3);border-top-color:#1a73e8;"></span> Creating project <strong>${escapeHtml(projectTitle)}</strong>${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 errMsg = err && err.message ? err.message : String(err || '')
const isApiDisabled = errMsg.includes('Apps Script API')
const errorDetail = (err && err.stack) || errMsg
const errorDetailBlock =
`<details style="margin-top:6px;font-size:12px;">` +
`<summary style="cursor:pointer;color:#888;">Error detail</summary>` +
`<div class="error-detail">${escapeHtml(errorDetail)}</div>` +
`<button class="copy-btn" data-copy="${encodeURIComponent(errorDetail)}"` +
` onclick="navigator.clipboard.writeText(decodeURIComponent(this.dataset.copy)).catch(()=>{})">` +
`📋 Copy error</button>` +
`</details>`
if (isApiDisabled) {
return (
'❌ Deployment failed: User has not enabled the Apps Script API.' +
'<a href="https://script.google.com/home/usersettings" target="_blank" rel="noopener"' +
' style="display:block;margin-top:8px;">' +
'👉 Enable it at script.google.com/home/usersettings' +
'</a>' +
'<span style="font-size:12px;color:#888;display:block;margin-top:4px;">' +
'After enabling, wait a minute then try again.' +
'</span>' +
errorDetailBlock
)
}
return `❌ Deployment failed: ${escapeHtml(errMsg)}` + 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.
Expand Down
57 changes: 57 additions & 0 deletions deploy/tests/ui.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@
}) => {
await signIn(page)
// Default mock returns empty files array
await page.waitForTimeout(500)

Check warning on line 277 in deploy/tests/ui.spec.js

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this fixed wait with a synchronization on an observable condition.

See more on https://sonarcloud.io/project/issues?id=petry-projects_google-app-scripts&issues=AZ8-FY8oST99TqKM_cc5&open=AZ8-FY8oST99TqKM_cc5&pullRequest=431
await expect(page.locator('#step3-card')).toBeHidden()
})

Expand Down Expand Up @@ -1041,6 +1041,63 @@
)
})

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,
}) => {
Expand Down
Loading
Loading