feat(gmail): Google Workspace SDK integration — auto-detect interview invites from Gmail#663
feat(gmail): Google Workspace SDK integration — auto-detect interview invites from Gmail#663tauheedbuttt wants to merge 4 commits into
Conversation
…tion
Adds scan-gmail.mjs — a zero-LLM Gmail scanner that detects interview
invitation emails and automates downstream actions:
- OAuth2 auth flow with token persistence (calendar/token.json)
- Queries Gmail API with interview keyword filter (configurable days)
- Extracts company, role, date/time, meeting link, interviewer from email
- Deduplicates against existing applications.md entries
- Writes TSV tracker additions (status: Interview) via merge-tracker pattern
- Creates interview-prep/{company}-{role}.md stub files
- Adds Google Calendar events via Calendar API
- Flags calendar/credentials.json and token.json in .gitignore
Closes santifer#660
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Welcome to career-ops, @tauheedbuttt! Thanks for your first PR. A few things to know:
We'll review your PR soon. Join our Discord if you have questions. |
📝 WalkthroughWalkthroughAdds CLI, docs, profile example, package script and dependency, .gitignore entries, a new scan-gmail.mjs that scans Gmail for interview invitations, extracts normalized fields, deduplicates against the tracker, writes TSV/markdown artifacts, and optionally creates Google Calendar events via OAuth2. ChangesGmail Interview Scanner
Sequence Diagram (high-level scan flow): sequenceDiagram
participant CLI as scan-gmail.mjs
participant GmailAPI as Gmail API
participant Parser as parseEmail()
participant Deduper as dedup (applications.md)
participant Writers as TSV & prep writers
participant CalendarAPI as Google Calendar API
CLI->>GmailAPI: query messages (subject filters, newer_than:N)
GmailAPI-->>CLI: list of message IDs
loop per message
CLI->>GmailAPI: get full message payload
CLI->>Parser: decode & extract fields (company, role, date, link)
Parser-->>Deduper: normalized company::role
Deduper-->>CLI: skip or new (next tracker number)
alt new interview
CLI->>Writers: write TSV + prep file
alt calendar enabled and date present
CLI->>CalendarAPI: insert event (timed or all-day)
CalendarAPI-->>CLI: event URL / error
end
else skipped
CLI-->>Writers: record skip
end
end
CLI->>CLI: print summary, errors
Estimated code review effort🎯 4 (Complex) | ⏱️ ~75 minutes 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Tip 💬 Introducing Slack Agent: The best way for teams to turn conversations into code.Slack Agent is built on CodeRabbit's deep understanding of your code, so your team can collaborate across the entire SDLC without losing context.
Built for teams:
One agent for your entire SDLC. Right inside Slack. Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 7
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@scan-gmail.mjs`:
- Around line 172-173: Prevent token persistence during dry-run by wrapping the
mkdirSync(path.dirname(TOKEN_PATH), { recursive: true }) and
writeFileSync(TOKEN_PATH, JSON.stringify(tokens, null, 2)) calls in a
conditional that checks the CLI dry-run flag (e.g., options.dryRun or DRY_RUN).
If dry-run is true, skip both filesystem operations and emit a log message
indicating the token would have been written; otherwise perform the existing
mkdirSync/writeFileSync. Apply the same conditional around the duplicate writes
at the second occurrence (the calls at the other location referenced by lines
487-488), using the TOKEN_PATH and tokens identifiers so the exact write sites
are covered.
- Around line 139-140: Generate a cryptographically strong random state value
(e.g., using crypto.randomBytes), include it in the call to
oAuth2Client.generateAuthUrl by passing state, persist that state server-side
(or in a signed cookie/session) tied to the user before redirecting, and in the
OAuth callback handler verify the returned state parameter matches the stored
one before calling oAuth2Client.getToken or exchanging the code; if it doesn’t
match, abort the flow and return an error. Update both the auth URL creation
site (where SCOPES and oAuth2Client.generateAuthUrl are used) and the callback
handling site (where the code is exchanged) to implement this generation,
storage, and strict validation using the same state identifier.
- Around line 30-32: The code currently hardcodes CREDENTIALS_PATH and
TOKEN_PATH and never reads the profile's
google.credentials_path/google.token_path; update initialization to load
PROFILE_PATH (profile.yml) via your existing YAML loader, read
profile.google.credentials_path and profile.google.token_path (if present), and
use those values to set the credential/token path variables (replace or override
CREDENTIALS_PATH/TOKEN_PATH with credentialsPath/tokenPath) before any code that
uses them (e.g., the sections around the earlier references and the code near
lines 76-83 and 483-485); ensure you fall back to the current defaults
('calendar/credentials.json' and 'calendar/token.json') when profile entries are
missing.
- Around line 71-73: The code reads the '--days' arg into daysIdx and sets
scanDays via parseInt but doesn't validate the parsed value; ensure that when
daysIdx !== -1 you check that args[daysIdx + 1] exists and that
parseInt(args[daysIdx + 1], 10) returns a positive integer (not NaN and > 0); if
validation fails, set scanDays to null or throw a clear error before building
the Gmail query (and in the code paths that build the query, e.g., the place
that adds the newer_than:<n>d clause, only append that clause when scanDays is a
valid positive integer) so you never produce an invalid newer_than: query that
would break the Gmail API.
- Around line 448-452: The all-day event fallback currently sets allDay = true
and assigns startDateTime and endDateTime to the same ISO date string (today),
which produces an invalid zero-length all-day event because Google Calendar
expects end.date to be exclusive; update the logic around the variables allDay,
startDateTime and endDateTime so that when allDay is true you set startDateTime
to today (YYYY-MM-DD) and set endDateTime to the next calendar day (compute
today + 1 day and format as YYYY-MM-DD), and apply the same fix to the other
occurrence that sets startDateTime/endDateTime (the block referenced at the
other location).
- Around line 145-149: The code currently builds a shell command using the
opener variable and execSync with string interpolation (execSync(`${opener}
"${authUrl}"`)) which can be subject to shell injection; update this to call
child_process.spawn or child_process.execFile directly (importing
spawn/execFile) and invoke it with the opener as the command and authUrl as a
separate argument (e.g., spawn(opener, [authUrl], { stdio: 'ignore', detached:
true }) or execFile(opener, [authUrl], { stdio: 'ignore' })). Replace the
execSync call in the try block with this non-shell invocation, ensure the child
is properly detached or awaited and any errors are caught in the existing catch,
and keep using the opener and authUrl symbols so the change is localized.
In `@test-all.mjs`:
- Line 72: The test entry for the CLI help is incorrectly marked as
allowed-to-fail; update the test entry in test-all.mjs for the item with name
'scan-gmail.mjs --help' (the object currently containing expectExit: 0 and
allowFail: true) to remove or set allowFail to false so the --help path is
enforced in CI; leave expectExit: 0 unchanged and keep the entry in the same
test array so the script still runs without OAuth credentials.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 44bc6f85-04ec-4ad9-a83c-5664fb9cee9f
📒 Files selected for processing (7)
.gitignoreREADME.mdcalendar/.gitkeepconfig/profile.example.ymlpackage.jsonscan-gmail.mjstest-all.mjs
- Add CSRF state parameter to OAuth flow using crypto.randomBytes - Replace execSync shell interpolation with execFile (shell injection fix) - Skip token write during dry-run in both auth paths - Read credentials_path and token_path from profile.yml google config - Validate --days arg: must be positive integer, exit 1 on bad input - Fix all-day calendar event: end date set to next day (Google Calendar end is exclusive) - Update test-all.mjs: enforce scan-gmail graceful exit without credentials Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 4
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (2)
test-all.mjs (2)
75-84:⚠️ Potential issue | 🟠 Major | ⚡ Quick winThe
expectExitfield is defined but never validated.The test loop checks only whether the command threw an exception (via
result !== null), not whether the actual exit code matchesexpectExit. This means a script could exit with an unexpected code and still pass. For example, line 72 expects exit code 1, but if scan-gmail.mjs exits with code 2, the test will still warn as "expected without user data."🔧 Proposed fix to validate exit codes
Update the
run()helper to return both output and exit code, then validate againstexpectExit:function run(cmd, args = [], opts = {}) { try { if (Array.isArray(args) && args.length > 0) { - return execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf-8', timeout: 30000, ...opts }).trim(); + const output = execFileSync(cmd, args, { cwd: ROOT, encoding: 'utf-8', timeout: 30000, ...opts }).trim(); + return { output, exitCode: 0 }; } - return execSync(cmd, { cwd: ROOT, encoding: 'utf-8', timeout: 30000, ...opts }).trim(); + const output = execSync(cmd, { cwd: ROOT, encoding: 'utf-8', timeout: 30000, ...opts }).trim(); + return { output, exitCode: 0 }; } catch (e) { - return null; + return { output: e.stdout?.toString() || '', exitCode: e.status ?? 1 }; } }Then update the test loop:
-for (const { name, allowFail } of scripts) { - const result = run('node', name.split(' '), { stdio: ['pipe', 'pipe', 'pipe'] }); - if (result !== null) { - pass(`${name} runs OK`); - } else if (allowFail) { +for (const { name, expectExit, allowFail } of scripts) { + const { exitCode } = run('node', name.split(' '), { stdio: ['pipe', 'pipe', 'pipe'] }); + if (exitCode === expectExit) { + pass(`${name} runs OK (exit ${exitCode})`); + } else if (allowFail) { warn(`${name} exited with error (expected without user data)`); } else { - fail(`${name} crashed`); + fail(`${name} exited with code ${exitCode}, expected ${expectExit}`); } }Note: This fix assumes
execFileSync/execSyncexceptions expose.status(Node.js does provide this on child_process errors).🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test-all.mjs` around lines 75 - 84, The test loop uses result !== null to detect failure but never checks the expected exit code, so scripts with wrong exit codes still pass; update the run() helper to return both exitCode and output (e.g., return { exitCode, stdout/stderr }) and change the loop over scripts to compare the returned exitCode against the script's expectExit (fall back to 0 if expectExit is undefined), calling pass() only when exitCode === expectExit, warn() when exitCode differs but allowFail is true, and fail() otherwise; reference the run() function and the scripts array/expectExit property in your changes.
76-76: 🧹 Nitpick | 🔵 Trivial | 💤 Low valueConsider pre-splitting arguments in test definitions.
The current approach of
name.split(' ')assumes command arguments never contain spaces. While this works for the current hardcoded test entries, it's fragile. If a future test needs an argument containing spaces (e.g.,--message "hello world"), the split will break.♻️ Optional: Define args explicitly in test objects
const scripts = [ - { name: 'cv-sync-check.mjs', expectExit: 1, allowFail: true }, // fails without cv.md (normal in repo) - { name: 'verify-pipeline.mjs', expectExit: 0 }, - { name: 'normalize-statuses.mjs', expectExit: 0 }, - { name: 'dedup-tracker.mjs', expectExit: 0 }, - { name: 'merge-tracker.mjs', expectExit: 0 }, - { name: 'update-system.mjs check', expectExit: 0 }, - { name: 'scan-gmail.mjs --dry-run --days 7', expectExit: 1, allowFail: true }, // exits without credentials (expected without setup) + { name: 'cv-sync-check.mjs', args: ['cv-sync-check.mjs'], expectExit: 1, allowFail: true }, + { name: 'verify-pipeline.mjs', args: ['verify-pipeline.mjs'], expectExit: 0 }, + { name: 'normalize-statuses.mjs', args: ['normalize-statuses.mjs'], expectExit: 0 }, + { name: 'dedup-tracker.mjs', args: ['dedup-tracker.mjs'], expectExit: 0 }, + { name: 'merge-tracker.mjs', args: ['merge-tracker.mjs'], expectExit: 0 }, + { name: 'update-system.mjs check', args: ['update-system.mjs', 'check'], expectExit: 0 }, + { name: 'scan-gmail.mjs --dry-run --days 7', args: ['scan-gmail.mjs', '--dry-run', '--days', '7'], expectExit: 1, allowFail: true }, ]; -for (const { name, allowFail } of scripts) { - const result = run('node', name.split(' '), { stdio: ['pipe', 'pipe', 'pipe'] }); +for (const { name, args, expectExit, allowFail } of scripts) { + const result = run('node', args, { stdio: ['pipe', 'pipe', 'pipe'] });This makes argument boundaries explicit and handles future edge cases.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test-all.mjs` at line 76, The test harness currently derives process arguments by calling name.split(' '), which breaks when an argument contains spaces; instead, update the test definitions to provide an explicit args array (e.g., add an args property for each test entry) and change the invocation that uses name.split(' ') to use that array directly (replace name.split(' ') with the test.args array when calling run('node', ...)); ensure existing tests are migrated to the new shape (single-word commands can use [name] or split once) and keep the run(...) call signature unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@scan-gmail.mjs`:
- Around line 38-41: The current SCOPES array always includes the calendar scope
causing authorize() to request calendar access even when noCalendar is set;
change the code to build the OAuth scopes dynamically based on the noCalendar
flag (e.g., compute scopes = [gmail.readonly] and conditionally push
calendar.events only if noCalendar is false) and pass that scopes variable into
authorize() and any other authorize calls (including the later occurrence around
the event-insert branch where SCOPES is referenced) so calendar permission is
only requested when events will actually be inserted.
- Around line 510-513: The profile value profile.google.gmail_scan_days is not
validated before being used to build the Gmail query (days/newer_than), so
invalid values can cause Gmail API errors; update the startup logic around
loadProfile/resolveGooglePaths to validate both the CLI override scanDays and
the configuredDays (profile?.google?.gmail_scan_days) to be a positive integer
(>=1), rejecting floats, zero, negatives, non-numeric strings, and missing
values with a clear startup error message (or fallback to a safe default like 7
only after explicit validation), and apply the same validation before the code
that composes newer_than:${days}d (also update similar usage at the other
occurrence referenced around lines with newer_than) while ensuring missing data
directories are handled gracefully per *.mjs guidelines.
- Around line 471-483: The current catch branch turns parse failures of
interview.interviewDate into an all-day event for "today", which is incorrect;
change the logic so that if interview.interviewDate is missing/empty you keep
the all-day fallback behavior, but if interview.interviewDate is present and new
Date(interview.interviewDate) fails, do not create a false event—instead set a
calendarError on the record (e.g., calendarError = `invalid interviewDate:
${interview.interviewDate}`) and skip event creation (return/continue) so
startDateTime/endDateTime and allDay are not written; update handling around
interview.interviewDate, the try/catch and any callers that rely on
startDateTime/endDateTime to respect the calendarError flag.
In `@test-all.mjs`:
- Line 72: Add a new test entry to the tests array in test-all.mjs for the help
case so the script's help exits successfully without OAuth credentials: insert
an object with name 'scan-gmail.mjs --help' and expectExit: 0 (do not set
allowFail: true). Place it alongside the existing test entries (near the
'scan-gmail.mjs --dry-run --days 7' entry) so the test runner verifies help
output succeeds even when credentials are missing.
---
Outside diff comments:
In `@test-all.mjs`:
- Around line 75-84: The test loop uses result !== null to detect failure but
never checks the expected exit code, so scripts with wrong exit codes still
pass; update the run() helper to return both exitCode and output (e.g., return {
exitCode, stdout/stderr }) and change the loop over scripts to compare the
returned exitCode against the script's expectExit (fall back to 0 if expectExit
is undefined), calling pass() only when exitCode === expectExit, warn() when
exitCode differs but allowFail is true, and fail() otherwise; reference the
run() function and the scripts array/expectExit property in your changes.
- Line 76: The test harness currently derives process arguments by calling
name.split(' '), which breaks when an argument contains spaces; instead, update
the test definitions to provide an explicit args array (e.g., add an args
property for each test entry) and change the invocation that uses name.split('
') to use that array directly (replace name.split(' ') with the test.args array
when calling run('node', ...)); ensure existing tests are migrated to the new
shape (single-word commands can use [name] or split once) and keep the run(...)
call signature unchanged.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 71b64f14-97d6-4b4e-a428-55564dd47ad1
📒 Files selected for processing (2)
scan-gmail.mjstest-all.mjs
- Dynamic OAuth scopes: only request calendar.events scope when --no-calendar is not set; Gmail-only runs no longer prompt for unnecessary permissions - Validate profile gmail_scan_days: must be positive integer, exit 1 on invalid - Fix calendar fallback logic: if interviewDate is present but unparseable, throw a descriptive error (no false today event); all-day fallback only used when interviewDate is absent - test-all.mjs: add explicit args array to each script entry (no name.split) - test-all.mjs: add runScript() helper that returns actual exit code; loop now validates exitCode === expectExit instead of null check Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Actionable comments posted: 2
♻️ Duplicate comments (1)
test-all.mjs (1)
66-72:⚠️ Potential issue | 🟡 Minor | ⚡ Quick winMissing
--helptest flagged in past reviews.The test array includes a
--dry-runtest (line 72) but still lacks the--helptest requested in previous reviews. Help output should succeed without OAuth credentials and be enforced in CI to catch regressions in the basic CLI path.➕ Add the missing --help test
{ name: 'merge-tracker.mjs', args: ['merge-tracker.mjs'], expectExit: 0 }, { name: 'update-system.mjs check', args: ['update-system.mjs', 'check'], expectExit: 0 }, + { name: 'scan-gmail.mjs --help', args: ['scan-gmail.mjs', '--help'], expectExit: 0 }, { name: 'scan-gmail.mjs --dry-run --days 7', args: ['scan-gmail.mjs', '--dry-run', '--days', '7'], expectExit: 1, allowFail: true }, ];🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@test-all.mjs` around lines 66 - 72, Add a test entry for the CLI help invocation so the basic CLI path is validated without OAuth: insert an object similar to the existing entries (e.g. { name: 'scan-gmail.mjs --help', args: ['scan-gmail.mjs', '--help'], expectExit: 0 }) alongside other tests in the test array (near 'scan-gmail.mjs --dry-run --days 7'); ensure expectExit is 0 and do not set allowFail (or set allowFail: false) so CI will enforce it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@scan-gmail.mjs`:
- Around line 500-501: The event start/end objects currently use either { date:
startDateTime } or { dateTime: startDateTime } which omits timezone and can
mis-schedule events; update the branch that builds { start: { dateTime:
startDateTime }, end: { dateTime: endDateTime } } to include an explicit
timeZone property (e.g., { dateTime: startDateTime, timeZone: userTimeZone })
and obtain userTimeZone from a reliable source such as
Intl.DateTimeFormat().resolvedOptions().timeZone or the calendar settings,
and/or fall back to a configured default; also add a brief comment documenting
the limitation if timezone cannot be determined.
- Around line 462-488: The else branch that creates an all-day placeholder is
dead due to the early return at the top of addCalendarEvent; remove the
unreachable fallback block (the entire else { /* No date detected ... */ }
including the today/tomorrow logic and the allDay variable) and any now-unused
variables or comments (e.g., remove allDay and any related slicing logic) so
addCalendarEvent only handles the parsed interview.interviewDate path; keep the
initial guard (if (!interview.interviewDate) return null) and the
startDateTime/endDateTime assignment inside the parsed branch.
---
Duplicate comments:
In `@test-all.mjs`:
- Around line 66-72: Add a test entry for the CLI help invocation so the basic
CLI path is validated without OAuth: insert an object similar to the existing
entries (e.g. { name: 'scan-gmail.mjs --help', args: ['scan-gmail.mjs',
'--help'], expectExit: 0 }) alongside other tests in the test array (near
'scan-gmail.mjs --dry-run --days 7'); ensure expectExit is 0 and do not set
allowFail (or set allowFail: false) so CI will enforce it.
🪄 Autofix (Beta)
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: Path: .coderabbit.yaml
Review profile: ASSERTIVE
Plan: Pro
Run ID: 33f0ffe8-8c45-401b-a026-f42bb5ac08cc
📒 Files selected for processing (2)
scan-gmail.mjstest-all.mjs
- Add --help/-h handler: prints usage and exits 0 (no credentials needed) - Remove dead all-day fallback in addCalendarEvent: early return guard makes else branch unreachable; function now only handles present+parseable dates - Add timeZone to calendar event start/end via Intl.DateTimeFormat to prevent mis-scheduling across DST boundaries - test-all.mjs: add scan-gmail.mjs --help test with expectExit: 0, no allowFail Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
|
Hi! Could a maintainer please approve the workflow runs? This is my first contribution to the repo. All CodeRabbit comments have been resolved across 3 rounds of review. Thanks! |
Summary
Implements the full feature request from #660.
scan-gmail.mjs: zero-LLM Gmail scanner using Google's official OAuth2 SDKapplications.mdto avoid double-trackingInterview) following the merge-tracker patterninterview-prep/{company}-{role}.mdstub files for each new interview--no-calendar)calendar/token.jsoncalendar/credentials.jsonandcalendar/token.jsonadded to.gitignoregoogle:section inconfig/profile.example.ymlfor scan days and calendar IDpackage.jsonscript:npm run scan:gmailUsage
Acceptance criteria
node scan-gmail.mjsruns without LLM (pure API calls)credentials.jsonandtoken.jsonin.gitignorenode test-all.mjs --quick)Test plan
node scan-gmail.mjs --dry-runwith real Gmail account — verify detected interviews print correctly--dry-run— verify TSV additions created inbatch/tracker-additions/, prep files ininterview-prep/node merge-tracker.mjsafter scan — verifyapplications.mdupdated withInterviewstatusnode scan-gmail.mjs --no-calendar— verify no calendar event error when credentials lack Calendar scopenode test-all.mjs— all checks passCloses #660
🤖 Generated with Claude Code
Summary by CodeRabbit
New Features
Documentation
Tests
Chores