Skip to content

Instantly share code, notes, and snippets.

@ryogrid
Last active April 1, 2025 23:58
Show Gist options
  • Save ryogrid/f4de2dabc8e6ac4b53db9967bc4a8c96 to your computer and use it in GitHub Desktop.
Save ryogrid/f4de2dabc8e6ac4b53db9967bc4a8c96 to your computer and use it in GitHub Desktop.
Google Apps Script for pgsql-bugs ML reported bugs statuses automatic tracking (statuses are assumed by LLM)
// --- Configuration ---
// --- ここから設定 ---
// 1. Your Google Cloud Project API Key for Vertex AI (Gemini API)
// Google Cloudプロジェクトで生成したVertex AI API(Gemini API)のAPIキー
const API_KEY = ''; // <-- ここにAPIキーを貼り付けてください (Paste your API key here)
// 2. Your Google Cloud Project ID
// Google CloudプロジェクトID
const PROJECT_ID = ''; // <-- ここにプロジェクトIDを貼り付けてください (Paste your project ID here)
// 3. The Gmail label to search for bug reports
// 検索対象のGmailラベル名
const GMAIL_LABEL = 'pgsql-bugs'; // 例 (Example)
// 4. The name of the sheet tab in your Google Spreadsheet
// 記録先のGoogleスプレッドシートのシート名
const SHEET_NAME = 'statuses'; // 例 (Example)
// !! 注意: このシートには 'Thread ID', 'Subject', 'Report Date', 'Status', 'Last Checked', 'Last Message Date', 'Gmail Link' のヘッダーが必要です。
// !! Note: This sheet requires headers: 'Thread ID', 'Subject', 'Report Date', 'Status', 'Last Checked', 'Last Message Date', 'Gmail Link'
const REPORT_DATE_COLUMN_NAME = 'Report Date'; // ソートに使用する列のヘッダー名
// 5. Gemini API Endpoint and Model
// Gemini APIのエンドポイントとモデル名
const GEMINI_API_ENDPOINT = `https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash-thinking-exp-1219:generateContent?key=${API_KEY}`;
// 6. Prompt for Gemini API to determine the bug status
// 対応状況を判定するためのGemini APIへのプロンプト
const GEMINI_PROMPT_TEMPLATE = `
Analyze the content of the following email thread and determine the current status of the PostgreSQL bug report.
Points to analyze:
- Does the email subject contain a pattern like "BUG #[number]"?
- Does the body contain keywords indicating a fix, such as "fixed", "patch", "committed", "commit [ID]", "applied", or "merged"?
- Does it contain keywords indicating the issue will not be addressed, such as "will not fix", "not a bug", "invalid", or "wontfix"?
- Are developers participating in the discussion and indicating concrete actions (e.g., investigation, patch review, fix proposals)?
- Examples of developers:
- Peter Eisentraut
- Andres Freund
- Magnus Hagander
- Jonathan Katz
- Tom Lane
- Bruce Momjian
- Dave Page
- Laurenz Albe
- Ashutosh Bapat
- Oleg Bartunov
- Christoph Berg
- Nathan Bossart
- Joe Conway
- Dave Cramer
- Jeff Davis
- Bertrand Drouvot
- Andrew Dunstan
- Vik Fearing
- Stephen Frost
- Etsuro Fujita
- Peter Geoghegan
- Andrew Gierth
- Devrim Gテシndテシz
- Richard Guo
- Daniel Gustafsson
- Robert Haas
- Stacey Haysler
- テ〕varo Herrera
- Kyotaro Horiguchi
- Tatsuo Ishii
- Petr Jelinek
- Stefan Kaltenbrunner
- Amit Kapila
- Alexander Korotkov
- Alexander Lakhin
- Amit Langote
- Guillaume Lelarge
- Heikki Linnakangas
- Anastasia Lubennikova
- Fujii Masao
- Noah Misch
- Thomas Munro
- John Naylor
- Michael Paquier
- Melanie Plageman
- Paul Ramsey
- Dean Rasheed
- Julien Rouhaud
- David Rowley
- Masahiko Sawada
- Teodor Sigaev
- Steve Singe
- Pavel Stehule
- Tomas Vondra
- Mark Wong
- Is there a patch (mention of a .patch file or an attachment), and is there a review or discussion taking place?
- Has there been no new email for a certain period (e.g., more than a week)? (Except immediately after the report)
- If someone other than the reporter (mainly developers) states that the behavior is consistent with the expectation, mark the status as "Not a Bug".
- If someone other than the reporter (mainly developers) states that the behavior is consistent with the documentation, mark the status as "Not a Bug".
- Has someone other than the reporter (mainly developers) stated that it is a bug or that the behavior is not in line with the specification?
- If someone other than the reporter (mainly developers) stated that it is a bug or not in line with the specification, and also stated that it will not be fixed for some reason, mark the status as "Postponing Fix".
- Assume that the reporter considers the content to be a bug or defect when analyzing.
- If someone other than the reporter (mainly developers) mentions other project (not postgresql), mark the status as "Inappropriate Report".
- If only one developer other than the reporter has responded, mark the status as "Not a Bug" unless the developer has requested additional information or action to further refine the issue, or has described it as a bug or incorrect behavior.
-
Only select "Under Discussion (Investigating)" as the status when you are sure it is appropriate. In most cases where you would select "Under Discussion (Investigating)", "Not a Bug" is more likely to be appropriate.
Taking the above points into account, select only one of the most appropriate statuses from the options below.
Status options:
- New Report
- Inappropriate Report
- Under Discussion (Investigating)
- Not a Bug
- Bug
- Bug (Patch Proposed/Under Review)
- Bug (Fixed)
- Bug (Postponing Fix)
Email thread content:
---
{EMAIL_CONTENT}
---
Please respond with only the status in the following format:
Status: [Selected Status]
`;
//// 7. Delay between Gemini API calls in milliseconds (to respect rate limits)
//// Gemini API呼び出し間の遅延時間(ミリ秒)。10 RPM = 6秒/リクエスト
//const API_CALL_DELAY_MS = 6000; // 6 seconds
// 8. Number of threads to initially fetch from Gmail (adjust based on typical volume)
// Gmailから最初に取得するスレッド数(通常の量に応じて調整)
const GMAIL_FETCH_LIMIT = 150;
// 9. Maximum number of Gemini API calls allowed per script execution.
// 1回のスクリプト実行で許可されるGemini API呼び出しの最大回数。
const MAX_API_CALLS_PER_RUN = 12;
// 10. Re-check interval in hours for existing threads.
// 既存スレッドのステータス再チェック間隔(時間)。
const RECHECK_INTERVAL_HOURS = 12;
const MONTHS_TO_KEEP = 2;
// --- 設定ここまで ---
// --- End of Configuration ---
/**
* Main function to be triggered periodically.
* Fetches emails, analyzes status, updates the spreadsheet, sorts by Report Date, and deletes old records.
* 定期実行されるメイン関数。メールを取得し、状況を分析し、スプレッドシートを更新し、報告日でソートし、古い記録を削除します。
*/
function main() {
Logger.log('Starting bug tracking process...');
const ss = SpreadsheetApp.getActiveSpreadsheet();
const sheet = ss.getSheetByName(SHEET_NAME);
if (!sheet) {
Logger.log(`Error: Sheet "${SHEET_NAME}" not found.`);
SpreadsheetApp.getUi().alert(`Error: Sheet "${SHEET_NAME}" not found. Please create it or update the SHEET_NAME variable in the script.`);
return;
}
// Check for API Key placeholder
if (API_KEY === 'YOUR_API_KEY' || !API_KEY) {
Logger.log(`Error: API_KEY is not set. Please add your Gemini API key to the script.`);
SpreadsheetApp.getUi().alert(`Error: API_KEY is not set. Please add your Gemini API key to the script.`);
return;
}
let existingData;
try {
existingData = getExistingSheetData(sheet);
Logger.log(`Found ${Object.keys(existingData).length} existing threads in the sheet.`);
} catch (e) {
Logger.log(`Error reading sheet data: ${e}. Aborting.`);
return;
}
// --- Fetch Threads from Gmail ---
const searchLabel = `label:${GMAIL_LABEL}`;
let threads;
try {
threads = GmailApp.search(searchLabel, 0, GMAIL_FETCH_LIMIT);
Logger.log(`Fetched ${threads.length} threads with label "${GMAIL_LABEL}".`);
} catch (e) {
Logger.log(`Error searching Gmail: ${e}`);
return;
}
const threadsToUpdate = []; // Store threads that need full processing (API call)
const updatesToPerform = []; // Store direct sheet updates (no API call needed)
// --- 日付計算 ---
const now = new Date(); // 現在時刻を取得
const twelveHoursAgo = new Date(now.getTime() - RECHECK_INTERVAL_HOURS * 60 * 60 * 1000);
const fiveDaysAgo = new Date(now.getTime() - 10 * 24 * 60 * 60 * 1000);
// --- 追加: 新規スレッド追加と古い記録削除の基準となる日付 ---
const recordKeepThresholdDate = new Date(now);
recordKeepThresholdDate.setMonth(recordKeepThresholdDate.getMonth() - MONTHS_TO_KEEP);
recordKeepThresholdDate.setHours(0, 0, 0, 0); // 日付の比較基準
Logger.log(`Record keep threshold date (new threads must be newer than this): ${recordKeepThresholdDate.toISOString()}`);
// --- 追加ここまで ---
// --- Identify Threads to Process/Update ---
const processedThisRun = new Set(); // Keep track of thread IDs handled in this run
for (const thread of threads) {
const threadId = thread.getId();
// Avoid processing the same thread twice if fetched multiple times
if (processedThisRun.has(threadId)) {
continue;
}
const messages = thread.getMessages();
if (!messages || messages.length === 0) {
Logger.log(`Skipping thread ${threadId} as it has no messages.`);
processedThisRun.add(threadId);
continue;
}
const firstMessageDate = messages[0].getDate(); // Get first message date
const lastMessageDate = thread.getLastMessageDate();
if (!firstMessageDate) {
Logger.log(`Skipping thread ${threadId} as it has no valid first message date.`);
processedThisRun.add(threadId);
continue;
}
// 返信なし&5日以上経過のチェック用変数
const isSingleMessage = messages.length === 1;
const isOldAndUnreplied = isSingleMessage && firstMessageDate < fiveDaysAgo;
if (!existingData[threadId]) {
// --- 変更: 新規スレッドの日付チェックを追加 ---
if (firstMessageDate < recordKeepThresholdDate) {
Logger.log(`Thread ${threadId} is new but its report date (${firstMessageDate.toISOString()}) is older than ${MONTHS_TO_KEEP} months threshold. Skipping addition.`);
processedThisRun.add(threadId); // 処理済みとしてマーク (追加しない)
continue; // 次のスレッドへ
}
// --- 変更ここまで ---
// --- 既存の新規スレッド処理 (日付チェックを通過した場合) ---
if (isOldAndUnreplied) {
// 新規スレッドで「対応不要」の場合
const rowDataForIgnore = {
'Thread ID': threadId,
'Subject': thread.getFirstMessageSubject(),
'Report Date': firstMessageDate,
'Status': 'Inappropriate Report', // 新しいステータス
'Last Checked': new Date(),
'Last Message Date': lastMessageDate, // 最初のメールの日付と同じ
'Gmail Link': `https://mail.google.com/mail/u/0/#label/${GMAIL_LABEL.replace(/ /g, '+')}/${threadId}`
};
addNewRow(sheet, rowDataForIgnore); // API呼び出しなしで直接追加
Logger.log(`Thread ${threadId} is new, unreplied, and older than 10 days. Added directly as 'Inappropriate Report'.`);
processedThisRun.add(threadId);
} else {
// 新規スレッドで「対応不要」でない場合(通常処理)
threadsToUpdate.push({ thread: thread, isNew: true, reportDate: firstMessageDate });
Logger.log(`Thread ${threadId} is new and within ${MONTHS_TO_KEEP} months. Added to processing list for analysis.`);
processedThisRun.add(threadId);
}
// --- 既存の新規スレッド処理ここまで ---
} else {
// --- 既存スレッドの処理 (変更なし) ---
const sheetData = existingData[threadId];
const sheetLastMessageDate = sheetData.lastMessageDate ? new Date(sheetData.lastMessageDate) : null;
const sheetLastCheckedDate = sheetData.lastCheckedDate ? new Date(sheetData.lastCheckedDate) : null;
const sheetReportDate = sheetData.reportDate ? new Date(sheetData.reportDate) : firstMessageDate; // Fallback
const currentStatus = sheetData.status; // 既存ステータスを取得
const hasNewMessages = !sheetLastMessageDate || lastMessageDate.getTime() > sheetLastMessageDate.getTime();
const isDueForCheck = !sheetLastCheckedDate || sheetLastCheckedDate < twelveHoursAgo;
// 既存スレッドで「対応不要」の条件チェック
if (isOldAndUnreplied && currentStatus !== 'Inappropriate Report') {
updatesToPerform.push({
action: 'updateIgnoredStatus',
row: sheetData.row,
threadId: threadId,
lastCheckedDate: new Date(),
lastMessageDate: lastMessageDate
});
Logger.log(`Thread ${threadId} is existing, unreplied, and older than 10 days. Queued for 'Inappropriate Report' status update.`);
} else if (!isOldAndUnreplied) { // 「対応不要」でない場合のみ通常の更新ロジックへ
if (hasNewMessages) {
if (isDueForCheck) {
threadsToUpdate.push({ thread: thread, isNew: false, row: sheetData.row, reportDate: sheetReportDate });
Logger.log(`Thread ${threadId} has new messages and is due for check. Added to processing list.`);
} else {
updatesToPerform.push({
action: 'updateLastMessageDate',
row: sheetData.row,
threadId: threadId,
lastMessageDate: lastMessageDate
});
}
} else { // No new messages
if (isDueForCheck) {
updatesToPerform.push({
action: 'updateLastChecked',
row: sheetData.row,
threadId: threadId,
lastCheckedDate: new Date()
});
}
}
} else if (isOldAndUnreplied && currentStatus === 'Inappropriate Report') {
// 既にIgnoredなら何もしない
// Logger.log(`Thread ${threadId} is already marked as 'Inappropriate Report'. Skipping.`);
}
processedThisRun.add(threadId); // Mark as handled for this run
// --- 既存スレッドの処理ここまで ---
}
} // end of for loop over threads
// --- Perform direct sheet updates (no API calls) ---
Logger.log(`Performing ${updatesToPerform.length} direct sheet updates...`);
for (const update of updatesToPerform) {
try {
if (update.action === 'updateLastMessageDate') {
updateSheetRow(sheet, update.row, { 'Last Message Date': update.lastMessageDate });
} else if (update.action === 'updateLastChecked') {
updateSheetRow(sheet, update.row, { 'Last Checked': update.lastCheckedDate });
} else if (update.action === 'updateIgnoredStatus') {
updateSheetRow(sheet, update.row, {
'Status': 'Inappropriate Report',
'Last Checked': update.lastCheckedDate,
'Last Message Date': update.lastMessageDate // 更新
});
Logger.log(`Updated status to 'Inappropriate Report' for thread ${update.threadId} (row ${update.row})`);
}
} catch(e) {
Logger.log(`Error performing direct update for thread ${update.threadId} (row ${update.row}): ${e}`);
}
}
Logger.log('Direct sheet updates finished.');
// --- Process threads requiring API analysis ---
// (このループ内のロジックに変更はありません)
Logger.log(`Processing ${threadsToUpdate.length} threads requiring analysis...`);
let apiCallCount = 0;
for (let i = 0; i < threadsToUpdate.length; i++) {
if (apiCallCount >= MAX_API_CALLS_PER_RUN) {
Logger.log(`Reached maximum API call limit (${MAX_API_CALLS_PER_RUN}) for this run. Stopping analysis.`);
break;
}
const item = threadsToUpdate[i];
const thread = item.thread;
const threadId = thread.getId();
const messages = thread.getMessages();
if (!messages || messages.length === 0) {
Logger.log(`Skipping thread ${threadId} again as it has no messages.`);
continue;
}
Logger.log(`Processing thread ${threadId} (${i + 1}/${threadsToUpdate.length}, API Call #${apiCallCount + 1})...`);
const combinedContent = combineMessages(messages);
//if (apiCallCount > 0) {
// Utilities.sleep(API_CALL_DELAY_MS);
//}
let status = 'Error: Analysis Failed';
try {
status = analyzeWithGemini(combinedContent);
Logger.log(`Gemini analysis for ${threadId} resulted in status: ${status}`);
} catch (e) {
Logger.log(`Error calling/analyzing Gemini API for thread ${threadId}: ${e}`);
status = 'Error: Analysis Exception';
}
apiCallCount++;
const reportDate = item.reportDate;
const subject = thread.getFirstMessageSubject();
const lastMessageDate = thread.getLastMessageDate();
const threadLink = `https://mail.google.com/mail/u/0/#label/${GMAIL_LABEL.replace(/ /g, '+')}/${threadId}`;
const rowData = {
'Thread ID': threadId,
'Subject': subject,
'Report Date': reportDate,
'Status': status,
'Last Checked': new Date(),
'Last Message Date': lastMessageDate,
'Gmail Link': threadLink
};
try {
if (item.isNew) {
addNewRow(sheet, rowData);
Logger.log(`Added analysis result for new thread ${threadId}`);
} else {
const updateData = {
'Status': status,
'Last Checked': rowData['Last Checked'],
'Last Message Date': rowData['Last Message Date'],
};
updateSheetRow(sheet, item.row, updateData);
Logger.log(`Updated analysis result for thread ${threadId} (row ${item.row})`);
}
} catch(e) {
Logger.log(`Error updating sheet for thread ${threadId}: ${e}`);
}
} // End of threadsToUpdate loop
// --- Sort Sheet Data ---
try {
Logger.log(`Sorting sheet by '${REPORT_DATE_COLUMN_NAME}' descending...`);
sortSheetByDate(sheet, REPORT_DATE_COLUMN_NAME);
Logger.log("Sheet sorted.");
} catch (e) {
Logger.log(`Error sorting sheet: ${e}`);
}
// --- Delete Old Records ---
try {
Logger.log(`Checking for records older than ${MONTHS_TO_KEEP} months to delete...`);
// Use the same threshold date calculated earlier
deleteOldRecords(sheet, REPORT_DATE_COLUMN_NAME, MONTHS_TO_KEEP);
} catch (e) {
Logger.log(`Error during old record deletion: ${e}`);
}
Logger.log(`Bug tracking process finished. Performed ${apiCallCount} API calls.`);
} // <<< END OF main() FUNCTION >>>
/**
* Reads existing data from the sheet.
* @param {GoogleAppsScript.Spreadsheet.Sheet} sheet The sheet object.
* @return {Object} An object where keys are Thread IDs and values contain row, dates, and status.
* @throws {Error} If essential header columns ('Thread ID', 'Report Date', 'Last Checked') are missing.
*/
function getExistingSheetData(sheet) {
const dataRange = sheet.getDataRange();
const values = dataRange.getValues(); // Gets all data including headers
const existingData = {};
if (values.length < 1) return existingData; // Empty sheet
const headers = values[0].map(h => String(h).trim()); // Get headers from the first row
const threadIdCol = headers.indexOf('Thread ID');
const reportDateCol = headers.indexOf(REPORT_DATE_COLUMN_NAME); // Index for report date
const lastMsgDateCol = headers.indexOf('Last Message Date');
const lastCheckedCol = headers.indexOf('Last Checked');
const statusCol = headers.indexOf('Status'); // --- 追加: Status列のインデックス取得 ---
// Ensure required columns exist - Throw error to stop execution if missing
if (threadIdCol === -1) throw new Error("'Thread ID' column not found. Please add it.");
if (reportDateCol === -1) throw new Error(`'${REPORT_DATE_COLUMN_NAME}' column not found. Please add it for sorting and deletion.`);
if (lastCheckedCol === -1) throw new Error("'Last Checked' column not found. Please add it for re-check logic.");
// Optional columns warning
if (lastMsgDateCol === -1) Logger.log("Warning: 'Last Message Date' column not found.");
if (statusCol === -1) Logger.log("Warning: 'Status' column not found. Cannot check existing status for Ignored logic."); // --- 追加 ---
// Start from row 1 (index 1) to skip headers
for (let i = 1; i < values.length; i++) {
const threadId = String(values[i][threadIdCol]).trim();
if (threadId) {
const reportDate = values[i][reportDateCol] instanceof Date ? values[i][reportDateCol] : null;
const lastMessageDate = (lastMsgDateCol !== -1 && values[i][lastMsgDateCol] instanceof Date) ? values[i][lastMsgDateCol] : null;
const lastCheckedDate = (lastCheckedCol !== -1 && values[i][lastCheckedCol] instanceof Date) ? values[i][lastCheckedCol] : null;
const status = (statusCol !== -1 && values[i][statusCol]) ? String(values[i][statusCol]).trim() : null; // --- 変更: Status取得、nullチェック追加 ---
if (!reportDate) {
Logger.log(`Warning: Invalid or missing date in '${REPORT_DATE_COLUMN_NAME}' column for thread ID ${threadId} at row ${i+1}. This row might sort/delete incorrectly.`);
}
existingData[threadId] = {
row: i + 1, // Sheet rows are 1-based
reportDate: reportDate,
lastMessageDate: lastMessageDate,
lastCheckedDate: lastCheckedDate,
status: status // --- 追加: Statusを格納 ---
};
}
}
return existingData;
}
/**
* Combines message bodies from a thread into a single string.
* @param {Array<GoogleAppsScript.Gmail.GmailMessage>} messages Array of Gmail messages.
* @return {string} Combined text content of the messages.
*/
function combineMessages(messages) {
let combined = "";
messages.forEach((message, index) => {
const date = message.getDate();
const from = message.getFrom();
const subject = message.getSubject();
let body = message.getPlainBody();
if (!body) {
body = message.getBody()
.replace(/<style([\s\S]*?)<\/style>/gi, '')
.replace(/<script([\s\S]*?)<\/script>/gi, '')
.replace(/<[^>]*>/g, ' ')
.replace(/\s+/g, ' ')
.trim();
}
combined += `--- Message ${index + 1} ---\n`;
combined += `Date: ${date}\n`;
combined += `From: ${from}\n`;
combined += `Subject: ${subject}\n`;
combined += `Body:\n${body.substring(0, 3000)}\n\n`;
});
return combined;
}
/**
* Calls the Gemini API to analyze the email content and determine the status.
* @param {string} emailContent The combined content of the email thread.
* @return {string} The determined status string.
*/
function analyzeWithGemini(emailContent) {
let prompt = GEMINI_PROMPT_TEMPLATE;
try {
if (emailContent.length > 200000) {
Logger.log("Warning: Input emailContent is very large, truncating before API call.");
emailContent = emailContent.substring(0, 200000) + "... [Truncated Internally]";
}
prompt = prompt.replace('{EMAIL_CONTENT}', emailContent);
} catch (e) {
Logger.log(`Error preparing prompt: ${e}. Using fallback content.`);
return 'Error: Prompt Preparation Failed';
}
const MAX_PROMPT_LENGTH = 180000;
if (prompt.length > MAX_PROMPT_LENGTH) {
Logger.log(`Warning: Final prompt length (${prompt.length}) exceeds limit (${MAX_PROMPT_LENGTH}). Truncating.`);
prompt = prompt.substring(0, MAX_PROMPT_LENGTH) + "\n... [Prompt Truncated]";
}
const payload = {
contents: [{
parts: [{
text: prompt
}]
}],
};
const options = {
method: 'post',
contentType: 'application/json',
payload: JSON.stringify(payload),
muteHttpExceptions: true
};
const response = UrlFetchApp.fetch(GEMINI_API_ENDPOINT, options);
const responseCode = response.getResponseCode();
const responseBody = response.getContentText();
if (responseCode === 200) {
try {
const jsonResponse = JSON.parse(responseBody);
if (jsonResponse.candidates && jsonResponse.candidates.length > 0 &&
jsonResponse.candidates[0].content && jsonResponse.candidates[0].content.parts &&
jsonResponse.candidates[0].content.parts.length > 0 && jsonResponse.candidates[0].content.parts[0].text)
{
let textResponse = jsonResponse.candidates[0].content.parts[0].text;
const match = textResponse.match(/Status:\s*(.+)/i);
if (match && match[1]) {
return match[1].trim();
} else {
Logger.log(`Could not parse status like 'Status: ...' from response: ${textResponse}`);
return textResponse.trim().substring(0,150);
}
} else if (jsonResponse.candidates && jsonResponse.candidates.length > 0 && jsonResponse.candidates[0].finishReason) {
Logger.log(`Gemini call finished with reason: ${jsonResponse.candidates[0].finishReason}.`);
return `Error: Model Finish (${jsonResponse.candidates[0].finishReason})`;
}
else {
Logger.log(`Unexpected Gemini response structure: ${responseBody}`);
return 'Error: Unexpected API Response Structure';
}
} catch (e) {
Logger.log(`Error parsing Gemini JSON response: ${e}\nResponse Body: ${responseBody}`);
return 'Error: Parsing API Response Failed';
}
} else {
let errorStatus = `Error: API Call Failed (${responseCode})`;
Logger.log(`Error calling Gemini API: Response Code ${responseCode}. Response Body: ${responseBody}`);
if (responseCode === 429) errorStatus = 'Error: API Rate Limit';
else if (responseCode === 403) errorStatus = 'Error: API Permission Denied';
else if (responseCode === 400) errorStatus = 'Error: API Bad Request';
else if (responseCode >= 500) errorStatus = `Error: API Server Error (${responseCode})`;
return errorStatus;
}
}
/**
* Adds a new row to the spreadsheet with the provided data.
* @param {GoogleAppsScript.Spreadsheet.Sheet} sheet The sheet object.
* @param {Object} rowData An object where keys are header names and values are cell values.
*/
function addNewRow(sheet, rowData) {
// This function now just prepares the data. Sorting happens later.
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(h => String(h).trim());
const newRow = headers.map(header => rowData.hasOwnProperty(header) ? rowData[header] : "");
sheet.appendRow(newRow); // Appends to the bottom temporarily
}
/**
* Updates specific cells in an existing row in the spreadsheet.
* @param {GoogleAppsScript.Spreadsheet.Sheet} sheet The sheet object.
* @param {number} rowNumber The 1-based row number to update.
* @param {Object} rowDataToUpdate An object with header names and new values.
*/
function updateSheetRow(sheet, rowNumber, rowDataToUpdate) {
const headers = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0].map(h => String(h).trim());
headers.forEach((header, index) => {
if (rowDataToUpdate.hasOwnProperty(header)) {
const colIndex = index + 1;
const value = rowDataToUpdate[header];
try {
sheet.getRange(rowNumber, colIndex).setValue(value);
} catch (e) {
Logger.log(`Error setting value '${value}' in row ${rowNumber}, col ${colIndex} (Header: ${header}): ${e}`);
}
}
});
}
/**
* Sorts the entire sheet (excluding header row) by a specified date column in descending order.
* 指定された日付列に基づいて、シート全体(ヘッダー行を除く)を降順でソートします。
* @param {GoogleAppsScript.Spreadsheet.Sheet} sheet The sheet object to sort.
* @param {string} dateColumnName The exact header name of the date column to sort by.
*/
function sortSheetByDate(sheet, dateColumnName) {
const lastRow = sheet.getLastRow();
// Only sort if there is data beyond the header row
if (lastRow < 2) {
Logger.log("No data rows to sort.");
return;
}
const headerRow = sheet.getRange(1, 1, 1, sheet.getLastColumn()).getValues()[0];
const dateColumnIndex = headerRow.findIndex(header => String(header).trim() === dateColumnName);
if (dateColumnIndex === -1) {
Logger.log(`Error: Column "${dateColumnName}" not found for sorting.`);
throw new Error(`Sort column "${dateColumnName}" not found.`); // Stop if column missing
}
// Get the range to sort (all data rows)
const dataRange = sheet.getRange(2, 1, lastRow - 1, sheet.getLastColumn());
// Sort the range based on the date column index, descending
// Ensure values in the date column are treated as Dates for proper sorting
dataRange.sort({
column: dateColumnIndex + 1, // Spreadsheet range columns are 1-based
ascending: false // false for descending order (newest first)
});
// Optional: Add visual confirmation or log counts
Logger.log(`Sorted ${lastRow - 1} data rows.`);
}
/**
* Deletes rows from the sheet where the date in the specified column is older than a threshold.
* 指定された列の日付がしきい値よりも古い行をシートから削除します。
* @param {GoogleAppsScript.Spreadsheet.Sheet} sheet The sheet object to modify.
* @param {string} dateColumnName The header name of the date column to check.
* @param {number} monthsThreshold The age threshold in months (e.g., 2 for older than 2 months).
*/
function deleteOldRecords(sheet, dateColumnName, monthsThreshold) {
Logger.log(`Starting deletion process for records older than ${monthsThreshold} months...`);
const lastRow = sheet.getLastRow();
if (lastRow < 2) {
Logger.log("No data rows to check for deletion.");
return; // No data to delete
}
const dataRange = sheet.getDataRange();
const values = dataRange.getValues(); // Get all data including headers
const headers = values[0].map(h => String(h).trim());
const dateColumnIndex = headers.indexOf(dateColumnName);
if (dateColumnIndex === -1) {
Logger.log(`Error: Column "${dateColumnName}" not found for deletion check.`);
// Optionally throw an error or return early
// throw new Error(`Column "${dateColumnName}" not found for deletion check.`);
return;
}
const now = new Date();
const thresholdDate = new Date(now);
// Calculate the date `monthsThreshold` months ago
thresholdDate.setMonth(thresholdDate.getMonth() - monthsThreshold);
// Set time to start of the day for consistent comparison (optional, depends on desired precision)
thresholdDate.setHours(0, 0, 0, 0);
Logger.log(`Threshold date for deletion: ${thresholdDate.toISOString()}`);
const rowsToDelete = [];
// Iterate backwards through the data rows (excluding header)
for (let i = lastRow -1; i >= 1; i--) { // i is 0-based index for values array, maps to row i+1
const reportDateValue = values[i][dateColumnIndex];
// Check if the value is a valid Date object
if (reportDateValue instanceof Date && !isNaN(reportDateValue.getTime())) {
const reportDate = new Date(reportDateValue); // Create copy to avoid modifying original if needed
reportDate.setHours(0,0,0,0); // Normalize time for comparison (optional)
if (reportDate < thresholdDate) {
// Add the actual row number (1-based) to the list
rowsToDelete.push(i + 1);
// Logger.log(`Marking row ${i + 1} for deletion (Date: ${reportDateValue.toISOString()})`);
}
} else {
// Log rows with invalid or missing dates in the target column if needed
// Logger.log(`Skipping row ${i + 1} due to invalid/missing date in column '${dateColumnName}'. Value: ${reportDateValue}`);
}
}
if (rowsToDelete.length > 0) {
Logger.log(`Found ${rowsToDelete.length} rows to delete.`);
// Sort rows in descending order to avoid index shifting issues during deletion
rowsToDelete.sort((a, b) => b - a);
// Delete rows one by one, starting from the bottom
rowsToDelete.forEach(rowNum => {
try {
sheet.deleteRow(rowNum);
// Logger.log(`Deleted row ${rowNum}.`);
} catch (e) {
Logger.log(`Error deleting row ${rowNum}: ${e}`);
}
});
Logger.log(`Finished deleting ${rowsToDelete.length} old records.`);
} else {
Logger.log("No records found older than the threshold.");
}
}
// --- Optional: Trigger Management Functions ---
function createTimeDrivenTrigger() {
deleteTriggersByName('main'); // Avoid duplicates
ScriptApp.newTrigger('main')
.timeBased()
.everyHours(1) // Adjust frequency as needed
.create();
Logger.log('Time-driven trigger created for main function (runs approx. every hour).');
SpreadsheetApp.getUi().alert('Time-driven trigger created successfully! It will run approximately every hour.');
}
function deleteTriggersByName(functionName) {
const triggers = ScriptApp.getProjectTriggers();
let deletedCount = 0;
triggers.forEach(trigger => {
if (trigger.getHandlerFunction() === functionName) {
try {
ScriptApp.deleteTrigger(trigger);
deletedCount++;
Logger.log('Deleted trigger ID: ' + trigger.getUniqueId());
} catch (e) {
Logger.log(`Failed to delete trigger ${trigger.getUniqueId()}: ${e}`);
}
}
});
if (deletedCount > 0) Logger.log(`Deleted ${deletedCount} triggers for the '${functionName}' function.`);
else Logger.log(`No triggers found for the '${functionName}' function.`);
return deletedCount;
}
function deleteAllMainTriggers() {
const count = deleteTriggersByName('main');
SpreadsheetApp.getUi().alert(`Deleted ${count} triggers for the 'main' function.`);
}
@ryogrid
Copy link
Author

ryogrid commented Mar 30, 2025

Google Apps Script: Gmail Bug Report Tracking Program Specification

1. Purpose of the Program

Automate the tracking of bug reports by periodically retrieving and analyzing email threads with a specified Gmail label (e.g., 'pgsql-bugs'), determining the response status using the Gemini API, and recording/updating the results in a Google Spreadsheet.

2. Main Features

2.1. Periodic Execution

  • Automatically triggered on a regular basis via a time-based trigger associated with the Google Spreadsheet.
  • Execution frequency: Every hour (configurable)

2.2. Retrieving Emails from Gmail

  • Target: Email threads labeled with the specified Gmail label (e.g., pgsql-bugs).
  • Retrieval Range: Uses GmailApp.search to retrieve relatively recent threads with the label, up to a defined maximum.
  • Content Processing: All email bodies in each thread are concatenated in chronological order and processed as a single text block.
  • Duplicate Prevention: Manages recorded thread IDs in the spreadsheet and determines processing targets using the following logic:
    • New Thread: Threads not yet recorded in the sheet.
    • Existing Thread (with updates): Already recorded threads with a latest message date newer than the sheet record.
    • Existing Thread (recheck): Already recorded threads with no new messages but where the specified time (e.g., 12 hours) has passed since the last check (Last Checked).

2.3. Status Determination via Gemini API

  • API Model: gemini-2.0-flash-thinking-exp-1219
  • Target: New threads, or existing threads with new messages and where the recheck interval has passed.
  • Prompt: Sends a prompt to the API including the concatenated email text and instructions for status judgment (analysis points, status choices).
    • Example Statuses: New Report, Confirmed (Awaiting Action), Under Discussion, In Progress, Fixed, Won’t Fix, Possibly Abandoned
    • Example Analysis Points: Subject patterns (BUG #...), keywords (fixed, patch, committed, etc.), developer participation/messages, presence/review status of patches, commit IDs, inactive periods
  • Rate Limit Control:
    • Introduce delay (e.g., 6 seconds) between API calls. <- Not required as each API call takes more than 6 seconds
    • Maximum number of API calls per trigger run is limited (e.g., 30). If the limit is reached, API calls for that run will stop.
  • Result Extraction: Parse the response text from the API and extract the status in the format "Status: [determined status]". If parsing fails, record an error status.

2.4. Recording/Updating to Google Spreadsheet

  • Target Sheet: Sheet with the specified name (e.g., Bug Tracking).
  • Required Headers: Thread ID, Subject, Report Date, Status, Last Checked, Last Message Date, Gmail Link.
  • New Records: Add the following information to a new row for newly processed threads:
    • Thread ID: Gmail thread ID.
    • Subject: Subject of the first email in the thread.
    • Report Date: Timestamp of the first email.
    • Status: Status determined by the Gemini API.
    • Last Checked: Timestamp when the script last checked the thread (current time).
    • Last Message Date: Timestamp of the last email in the thread.
    • Gmail Link: Direct URL to the thread.
  • Updating Existing Records:
    • Status Update: For existing threads analyzed by the API, update Status, Last Checked, and Last Message Date.
    • Update Last Message Date Only: If there are new messages but recheck interval hasn't passed, update only Last Message Date.
    • Update Last Checked Only: If there are no new messages but the recheck interval has passed, update only Last Checked with the current time.
  • Sorting: After all recording/updating is done, sort the entire sheet (excluding the header) by the Report Date column in descending order (newest on top).

2.5. Deleting Old Records

  • At the end of script execution, automatically delete records with Report Date older than the specified duration (e.g., 2 months) from the current date.

3. Prerequisites

  • A Google Cloud Platform project exists with the Vertex AI API enabled.
  • An API key has been obtained from the above project.
  • A Google Spreadsheet is created with the specified sheet name and headers.
  • A Google Apps Script project is linked to the spreadsheet.

4. Configuration Parameters (within the script)

  • API_KEY: Google Cloud API key.
  • PROJECT_ID: Google Cloud project ID.
  • GMAIL_LABEL: Gmail label name to search.
  • SHEET_NAME: Name of the sheet (tab) for recording.
  • REPORT_DATE_COLUMN_NAME: Header name of the date column used for sorting and deletion.
  • GEMINI_API_ENDPOINT: URL of the Gemini API endpoint.
  • API_CALL_DELAY_MS: Delay between API calls in milliseconds.
  • GMAIL_FETCH_LIMIT: Max number of threads to attempt to fetch from Gmail at once.
  • MAX_API_CALLS_PER_RUN: Max number of API calls per trigger execution.
  • RECHECK_INTERVAL_HOURS: Interval (in hours) for rechecking status of existing threads.
  • MONTHS_TO_KEEP (within main function): Duration (in months) to retain records.

5. How to Run

  1. Open the target Google Spreadsheet, then go to “Extensions” > “Apps Script” to open the script editor.
  2. Paste the provided script code into the editor.
  3. Enter and verify required information (e.g., API key) in the "Configuration Parameters" section of the script.
  4. Save the script (assign a project name).
  5. In the top bar, select the main function and click the “Run” button.
  6. During the first run, a permission screen will appear—review and grant the necessary permissions (Gmail, Sheets, external services).
  7. Open the “Triggers” section (clock icon) and click “Add Trigger”.
  8. Create a trigger with the following settings:
    • Function to run: main
    • Deployment: Head
    • Event source: Time-driven
    • Type of time-based trigger: Time-based Timer (e.g., Every hour)
    • Error notification settings: Optional
    • Click “Save” (re-authorization may be required)
    • Alternatively, you can manually execute the createTimeDrivenTrigger function once to create the trigger.

6. Output (Spreadsheet Columns)

  • Thread ID: Gmail thread ID (string)
  • Subject: Thread subject (string)
  • Report Date: Timestamp of the first email (datetime) - Sorted in descending order
  • Status: Determined status from Gemini API (string)
  • Last Checked: Last checked timestamp by the script (datetime)
  • Last Message Date: Timestamp of the last email (datetime)
  • Gmail Link: URL to the Gmail thread (string)

7. Notes

  • API Key Management: Treat API keys as confidential and prevent leakage outside the script.
  • API Usage Fees and Quotas: Gemini API usage may incur charges. Adjust trigger frequency and parameters to stay within free quotas or rate limits (e.g., 10 RPM, 4,000,000 TPM, 1,500 RPD).
  • Execution Time Limit: Google Apps Script has an execution time limit (typically 6 minutes). If processing many emails or facing slow API responses, consider adjusting GMAIL_FETCH_LIMIT and MAX_API_CALLS_PER_RUN.
  • Spreadsheet Headers: For the script to work correctly, the specified headers must exist exactly in the first row of the sheet.
  • Gemini API Accuracy: The status judgment depends on the accuracy of the Gemini API.

@ryogrid
Copy link
Author

ryogrid commented Apr 1, 2025

Output sample spread sheet

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment