Repository navigation
Expand file tree
/
Copy pathCode.gs
More file actions
2191 lines (1954 loc) · 88.7 KB
/
Copy pathCode.gs
File metadata and controls
2191 lines (1954 loc) · 88.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/**
* GitHub Repository Analytics Collector — v2
* ---------------------------------------
* Single-file Google Apps Script project. Collects metadata, languages,
* contributors, traffic, referrers/paths, branches, and PR/issue activity
* for every accessible GitHub repository, storing results in Google Sheets
* for long-term trend analysis (GitHub's traffic API only exposes 14 days).
*
* WHAT CHANGED FROM v1 (read this if you're diffing):
*
* PERFORMANCE (this is why v1 got slower every day):
* - v1 called upsertRows_()/appendRows_() once PER REPO PER SHEET. Each
* upsert first reads the ENTIRE existing sheet to build a key index
* (buildRowIndex_). So cost was O(repos × history-size), and history
* size grows every day forever. That's exactly the "gets slower and
* slower" pattern in the logs.
* - Fix: repo processing now only fetches data from GitHub and stages
* rows in memory (a "batch"). Sheet writes happen in one flush pass
* per sheet, so each sheet is read/indexed ONCE per run, not once per
* repo. Cost is now O(repos + history-size) instead of O(repos × history).
* - We still flush every BATCH_FLUSH_EVERY_N_REPOS repos (default 25),
* not only at the very end — so if Apps Script's hard 6-min execution
* ceiling kills the run mid-way, you don't lose an entire run's data,
* only the repos processed since the last flush.
* - getOrCreateSheet_() used to reformat the ENTIRE date column on every
* single call (there could be ~9 sheets × 11 repos = ~99 of these per
* run in v1). Now it only formats once, at sheet creation time.
*
* DATA CORRECTNESS / CLARITY:
* - v1 wrote the same number into both "Watcher Count" and "Subscriber
* Count" (GraphQL's `watchers` connection actually corresponds to
* REST's true subscriber count, not a separate watcher metric). v2
* keeps a single, correctly-labeled "Subscriber Count (true watchers)"
* column instead of two identical, confusingly-named ones.
* - "Network Count" is only an approximation (≈ fork count; GraphQL has
* no true network-size field). The column header now says so directly
* instead of relying on a code comment nobody reading the sheet sees.
* - PopularReferrers/PopularPaths are GitHub's rolling 14-day snapshot,
* not a daily time series — collecting them every day mostly just
* duplicates the same 14-day window. Default frequency is now weekly
* (configurable), and old rows get pruned periodically.
* - Contributors/Branches collection frequency is also configurable
* (still daily by default, but you can slow it down as history grows).
*
* CHARTS:
* - New "Public Charts" / "PublicChartData" tabs contain ONLY public
* repositories, so you can share/publish that tab without exposing
* anything about your private repos. "Charts"/"ChartData" continue
* to include everything.
* - New: org-wide totals chart (sum across all repos, not just per-repo
* lines), a language-mix snapshot chart, and a PR merge-rate snapshot
* chart, all previously-collected-but-never-charted data.
* - Per-repo breakdown charts now collapse to "Top N + Other" once you
* have more repos than fit legibly in one legend.
*
* Setup: see README.md. In short —
* 1. Set Script Property GITHUB_PAT with your GitHub token.
* 2. Run initializeSpreadsheet() once.
* 3. Run main() once to do a first collection pass.
* 4. Run setupTriggers() once to install the recurring schedule.
*
* Sections in this file (search for these headers):
* 1. CONFIGURATION & CREDENTIALS
* 2. GITHUB API CLIENT (REST + GraphQL, retry/backoff, rate limiting)
* 3. SPREADSHEET HELPERS (sheet bootstrapping, batched upsert-by-key)
* 4. LOGGING (Errors / ExecutionLog sheets)
* 5. REPOSITORY DISCOVERY (GraphQL bulk query)
* 6. ACTIVITY SUMMARY (per-repo GraphQL: PR/issue states, latest commit)
* 7. TRAFFIC COLLECTOR (REST: views/clones/referrers/paths)
* 8. CONTRIBUTORS COLLECTOR (REST)
* 9. BRANCHES COLLECTOR (REST)
* 10. STAGING / SHEET WRITERS (batch accumulation + flush)
* 11. MAIN ORCHESTRATOR + TRIGGERS
* 12. CHARTS / TREND VISUALIZATION (public + all-repo variants)
* 13. RAW API DEBUG EXPORT (manual diagnostic — dumps untouched JSON per repo/endpoint)
* 14. MAINTENANCE: DEDUPLICATE + REPAIR KEYED SHEETS (manual, on-demand)
* 15. CUSTOM MENU (onOpen())
*/
// ============================================================
// 1. CONFIGURATION & CREDENTIALS (FROM: Config.gs)
// ============================================================
/**
* Handles credentials (Script Properties) and the "Configuration" sheet.
* NOTHING in this file ever hardcodes a token.
*/
var PROP_KEY_PAT = 'GITHUB_PAT';
/**
* One-time setup helper. We intentionally do NOT hardcode a real token
* here — set it via Project Settings > Script Properties > GITHUB_PAT.
*/
function setGitHubToken() {
throw new Error(
'Do not hardcode your PAT in code. Instead go to ' +
'Project Settings > Script Properties and add a property named ' +
'GITHUB_PAT with your GitHub Personal Access Token as the value.'
);
}
function getGitHubToken_() {
var token = PropertiesService.getScriptProperties().getProperty(PROP_KEY_PAT);
if (!token) {
throw new Error(
'GitHub PAT not found. Set a Script Property named "GITHUB_PAT" ' +
'(Project Settings > Script Properties) before running this script.'
);
}
return token;
}
/**
* Default configuration. Overridden by rows in the "Configuration" sheet
* if present (Key / Value columns).
*/
var DEFAULT_CONFIG = {
RUN_FREQUENCY: 'daily', // 'daily' | 'every6hours'
INCLUDE_ARCHIVED: 'yes',
INCLUDE_FORKS: 'yes',
COLLECT_CONTRIBUTORS: 'yes',
COLLECT_RELEASES: 'yes',
COLLECT_BRANCHES: 'yes',
COLLECT_TRAFFIC: 'yes',
COLLECT_REFERRERS_PATHS: 'yes',
MAX_RUNTIME_MS: '300000', // stop gracefully before Apps Script's 6-min hard limit
API_PAUSE_THRESHOLD: '50', // pause/back off when remaining rate limit falls below this
ACTIVITY_QUERY_PAUSE_MS: '150', // small delay before each per-repo GraphQL call that includes
// a `search(...)` field (draft PR count) — GitHub applies
// extra abuse-detection scrutiny to search-heavy traffic, so a
// small stagger is cheap insurance against secondary rate limits.
CONTRIBUTORS_FREQUENCY_DAYS: '1', // collect contributors every N days (1 = every run)
BRANCHES_FREQUENCY_DAYS: '1', // collect branches every N days (1 = every run)
REFERRERS_PATHS_FREQUENCY_DAYS: '7', // referrers/paths are a rolling 14-day snapshot, so
// collecting daily mostly duplicates the same window —
// default to weekly. Set to 1 to restore daily collection.
PRUNE_ROLLING_SNAPSHOTS_DAYS: '60', // delete PopularReferrers/PopularPaths rows older than
// this many days (keeps that sheet from growing forever)
BATCH_FLUSH_EVERY_N_REPOS: '25', // flush staged rows to sheets after this many repos, in
// addition to a final flush — bounds data loss if Apps
// Script's hard execution ceiling kills the run mid-pass
CHART_TOP_N_REPOS: '15' // per-repo breakdown charts collapse to "Top N + Other"
// once there are more repos than this
};
/**
* Reads the Configuration sheet (Key | Value | Description) and merges
* it on top of DEFAULT_CONFIG. Creates the sheet with defaults if missing.
*/
function getConfig() {
var ss = getSpreadsheet_();
var sheet = ss.getSheetByName('Configuration');
var config = {};
for (var k in DEFAULT_CONFIG) config[k] = DEFAULT_CONFIG[k];
if (!sheet) {
sheet = ss.insertSheet('Configuration');
sheet.appendRow(['Key', 'Value', 'Description']);
sheet.getRange(1, 1, 1, 3).setFontWeight('bold');
var descriptions = {
RUN_FREQUENCY: 'daily or every6hours',
INCLUDE_ARCHIVED: 'yes/no - include archived repositories',
INCLUDE_FORKS: 'yes/no - include forked repositories',
COLLECT_CONTRIBUTORS: 'yes/no',
COLLECT_RELEASES: 'yes/no',
COLLECT_BRANCHES: 'yes/no',
COLLECT_TRAFFIC: 'yes/no',
COLLECT_REFERRERS_PATHS: 'yes/no - popular referrers/paths (requires push access)',
MAX_RUNTIME_MS: 'safety cutoff so the script exits before the 6-min Apps Script limit',
API_PAUSE_THRESHOLD: 'remaining GitHub API calls at which the script starts throttling',
ACTIVITY_QUERY_PAUSE_MS: 'ms to pause before each per-repo GraphQL search-containing call',
CONTRIBUTORS_FREQUENCY_DAYS: 'collect contributors every N days (1 = every run)',
BRANCHES_FREQUENCY_DAYS: 'collect branches every N days (1 = every run)',
REFERRERS_PATHS_FREQUENCY_DAYS: 'collect referrers/paths every N days (data is a rolling 14-day snapshot, so daily is mostly redundant)',
PRUNE_ROLLING_SNAPSHOTS_DAYS: 'delete PopularReferrers/PopularPaths rows older than N days',
BATCH_FLUSH_EVERY_N_REPOS: 'write staged data to sheets after every N repos processed',
CHART_TOP_N_REPOS: 'per-repo charts collapse to Top N + "Other" beyond this many repos'
};
var rows = [];
for (var key in DEFAULT_CONFIG) {
rows.push([key, DEFAULT_CONFIG[key], descriptions[key] || '']);
}
sheet.getRange(2, 1, rows.length, 3).setValues(rows);
sheet.autoResizeColumns(1, 3);
trimUnusedColumns_(sheet, 3);
return config;
}
var data = sheet.getDataRange().getValues();
for (var i = 1; i < data.length; i++) {
var key = String(data[i][0] || '').trim();
var value = data[i][1];
if (key) config[key] = String(value).trim();
}
trimUnusedColumns_(sheet, 3);
return config;
}
function configBool_(config, key) {
return String(config[key]).toLowerCase() === 'yes' || String(config[key]).toLowerCase() === 'true';
}
/**
* Deterministic "should this run today" check for frequency-gated
* collectors (contributors/branches/referrers-paths). Using day-of-year
* mod N means all repos in a run agree on the same decision without
* needing to persist any extra state.
*/
function shouldRunOnFrequency_(config, key, defaultDays) {
var days = Math.max(1, Number(config[key] || defaultDays));
if (days <= 1) return true;
var now = new Date();
var startOfYear = new Date(now.getFullYear(), 0, 0);
var dayOfYear = Math.floor((now.getTime() - startOfYear.getTime()) / 86400000);
return (dayOfYear % days) === 0;
}
// ============================================================
// 2. GITHUB API CLIENT (FROM: GitHubClient.gs)
// ============================================================
/**
* Thin wrapper around UrlFetchApp for talking to the GitHub REST and
* GraphQL APIs. Centralizes: auth headers, retry/backoff, rate-limit
* awareness, pagination (Link header), and error logging.
*/
var GITHUB_API_ROOT = 'https://api.github.com';
var GITHUB_GRAPHQL_URL = 'https://api.github.com/graphql';
var MAX_RETRIES = 4;
/** Tracks the last known rate limit state for this execution. */
var _rateLimitState = { remaining: null, resetEpochSeconds: null };
function ghHeaders_() {
return {
'Authorization': 'Bearer ' + getGitHubToken_(),
'Accept': 'application/vnd.github+json',
'X-GitHub-Api-Version': '2022-11-28'
};
}
/**
* Low level GET/POST with retry/backoff and rate-limit handling.
* Returns { status, headers, body (parsed JSON or null), rawText }.
*/
function ghFetch_(url, options) {
options = options || {};
var config = options.config || getConfig();
var pauseThreshold = Number(config.API_PAUSE_THRESHOLD || 50);
var attempt = 0;
while (true) {
attempt++;
maybeThrottleForRateLimit_(pauseThreshold);
var fetchOptions = {
method: options.method || 'get',
headers: ghHeaders_(),
muteHttpExceptions: true,
followRedirects: true
};
if (options.payload) {
fetchOptions.contentType = 'application/json';
fetchOptions.payload = JSON.stringify(options.payload);
}
var response;
try {
response = UrlFetchApp.fetch(url, fetchOptions);
} catch (e) {
logError_(options.repoFullName || '', url, 0, 'Network error: ' + e.message, attempt < MAX_RETRIES);
if (attempt >= MAX_RETRIES) throw e;
Utilities.sleep(backoffMs_(attempt));
continue;
}
var status = response.getResponseCode();
var headers = response.getAllHeaders();
updateRateLimitState_(headers);
if (status >= 200 && status < 300) {
var rawText = response.getContentText();
var parsed = null;
try { parsed = rawText ? JSON.parse(rawText) : null; } catch (e) { parsed = null; }
return { status: status, headers: headers, body: parsed, rawText: rawText };
}
if (status === 403 && (headers['retry-after'] || headers['Retry-After'])) {
var retryAfterSec = Number(headers['retry-after'] || headers['Retry-After']) || 5;
logError_(options.repoFullName || '', url, status, 'Secondary rate limit hit, retry-after=' + retryAfterSec + 's', true);
Utilities.sleep(retryAfterSec * 1000);
continue;
}
if (status === 403 && headers['x-ratelimit-remaining'] === '0') {
var resetHeader = Number(headers['x-ratelimit-reset']);
var waitMs = Math.max(0, resetHeader * 1000 - Date.now()) + 1000;
logError_(options.repoFullName || '', url, status, 'Rate limit exhausted, waiting ' + Math.round(waitMs / 1000) + 's', true);
Utilities.sleep(Math.min(waitMs, 5 * 60 * 1000));
continue;
}
// Traffic endpoints / permissions issue: not retryable, caller should
// treat this as "unavailable" rather than a hard failure. We still log
// it (as non-fatal / no-retry) so it's visible in the Errors sheet —
// this is the one case that used to be completely silent: a repo could
// be missing traffic/contributors/branches data indefinitely with
// nothing in Errors to explain why (insufficient PAT scope, missing
// Administration:Read on a fine-grained PAT, org SSO not authorized,
// or genuinely read-only access to that repo).
if (status === 403) {
logError_(options.repoFullName || '', url, status,
'Request forbidden (403) - likely insufficient PAT permissions for this endpoint (e.g. missing push access / Administration:Read, or org SSO not authorized for this token). Treated as "unavailable", not retried.',
false);
return { status: status, headers: headers, body: safeParse_(response.getContentText()), rawText: response.getContentText() };
}
if (status === 404) {
return { status: status, headers: headers, body: null, rawText: response.getContentText() };
}
if (status >= 500 || status === 502 || status === 503) {
logError_(options.repoFullName || '', url, status, 'Transient server error', attempt < MAX_RETRIES);
if (attempt >= MAX_RETRIES) {
return { status: status, headers: headers, body: null, rawText: response.getContentText() };
}
Utilities.sleep(backoffMs_(attempt));
continue;
}
logError_(options.repoFullName || '', url, status, safeErrorMessage_(response.getContentText()), false);
return { status: status, headers: headers, body: safeParse_(response.getContentText()), rawText: response.getContentText() };
}
}
function backoffMs_(attempt) {
var base = Math.pow(2, attempt - 1) * 1000;
return base + Math.floor(Math.random() * 250);
}
function safeParse_(text) {
try { return JSON.parse(text); } catch (e) { return null; }
}
function safeErrorMessage_(text) {
var parsed = safeParse_(text);
if (parsed && parsed.message) return parsed.message;
return (text || '').substring(0, 300);
}
function updateRateLimitState_(headers) {
var remaining = headers['x-ratelimit-remaining'];
var reset = headers['x-ratelimit-reset'];
if (remaining !== undefined) _rateLimitState.remaining = Number(remaining);
if (reset !== undefined) _rateLimitState.resetEpochSeconds = Number(reset);
}
function getRateLimitState() {
return _rateLimitState;
}
function maybeThrottleForRateLimit_(pauseThreshold) {
if (_rateLimitState.remaining !== null && _rateLimitState.remaining <= pauseThreshold) {
var waitMs = Math.max(0, (_rateLimitState.resetEpochSeconds || 0) * 1000 - Date.now()) + 1000;
if (waitMs > 0) {
Utilities.sleep(Math.min(waitMs, 5 * 60 * 1000));
}
}
}
/**
* GET a REST endpoint, following pagination via the Link header until
* exhausted or `maxPages` reached. Returns an array of all `body` items
* (assumes each page's body is a JSON array).
*/
function ghGetPaginated_(path, params, options) {
options = options || {};
var maxPages = options.maxPages || 50;
var perPage = (params && params.per_page) || 100;
var url = GITHUB_API_ROOT + path + '?' + buildQuery_(Object.assign({ per_page: perPage }, params || {}));
var results = [];
var page = 0;
while (url && page < maxPages) {
page++;
var res = ghFetch_(url, options);
if (res.status >= 200 && res.status < 300 && Array.isArray(res.body)) {
results = results.concat(res.body);
} else if (res.status !== 200) {
break;
}
url = extractNextLink_(res.headers['link'] || res.headers['Link']);
}
return results;
}
function extractNextLink_(linkHeader) {
if (!linkHeader) return null;
var parts = linkHeader.split(',');
for (var i = 0; i < parts.length; i++) {
var m = parts[i].match(/<([^>]+)>;\s*rel="next"/);
if (m) return m[1];
}
return null;
}
function buildQuery_(params) {
return Object.keys(params)
.filter(function (k) { return params[k] !== undefined && params[k] !== null; })
.map(function (k) { return encodeURIComponent(k) + '=' + encodeURIComponent(params[k]); })
.join('&');
}
/** GraphQL POST helper. */
function ghGraphQL_(query, variables, options) {
options = options || {};
var res = ghFetch_(GITHUB_GRAPHQL_URL, Object.assign({
method: 'post',
payload: { query: query, variables: variables || {} }
}, options));
if (res.body && res.body.errors && res.body.errors.length) {
logError_(options.repoFullName || '', 'graphql', res.status, 'GraphQL errors: ' + JSON.stringify(res.body.errors).substring(0, 500), false);
}
return res.body;
}
// ============================================================
// 3. SPREADSHEET HELPERS (FROM: SheetService.gs)
// ============================================================
/**
* Helpers for opening the workbook, creating sheets with headers, and
* doing batched "upsert" writes (update existing row if a key matches,
* otherwise append). Callers should batch ALL rows for a sheet across
* every repo into one call — see section 10 for the staging pattern that
* makes this possible. That's the fix for the "gets slower every day" bug:
* each of these functions reads the target sheet's existing data ONCE,
* not once per repo.
*/
var WORKBOOK_NAME = 'GitHub Analytics';
function getSpreadsheet_() {
var active = SpreadsheetApp.getActiveSpreadsheet ? SpreadsheetApp.getActiveSpreadsheet() : null;
if (active) return active;
var props = PropertiesService.getScriptProperties();
var id = props.getProperty('WORKBOOK_ID');
if (id) {
try {
return SpreadsheetApp.openById(id);
} catch (e) {
// fall through and recreate if it was deleted
}
}
var ss = SpreadsheetApp.create(WORKBOOK_NAME);
props.setProperty('WORKBOOK_ID', ss.getId());
return ss;
}
/** Parses an ISO 'yyyy-MM-dd' string into a real Date object (midnight UTC). */
function toIsoDate_(isoDateStr) {
return Utilities.parseDate(isoDateStr, 'Etc/UTC', 'yyyy-MM-dd');
}
/**
* Normalizes a cell/row value for use as part of an upsert key, so both a
* real Date object and an equivalent string produce the same key.
*
* This handles more than the obvious "Date object" case: manually pasted
* data (e.g. a spreadsheet migration) can leave a date-shaped cell as
* plain TEXT rather than a real Date — which looks identical on screen
* (both display "2026-07-03") but compares unequal unless we specifically
* detect and re-normalize it. We trim whitespace and, if the string looks
* date-like, parse and reformat it the same way a real Date object would
* be — so a text-typed "2026-07-03" (or one with a stray time component)
* matches a properly-typed Date cell for the same calendar day.
*/
function normalizeKeyValue_(v) {
if (v instanceof Date) {
return Utilities.formatDate(v, 'Etc/UTC', 'yyyy-MM-dd');
}
var s = String(v).trim();
if (/^\d{4}-\d{1,2}-\d{1,2}([ T].*)?$/.test(s)) {
try {
var parsed = new Date(s.length <= 10 ? s + 'T00:00:00Z' : s.replace(' ', 'T') + (s.indexOf('Z') === -1 && s.indexOf('+') === -1 ? 'Z' : ''));
if (!isNaN(parsed.getTime())) {
return Utilities.formatDate(parsed, 'Etc/UTC', 'yyyy-MM-dd');
}
} catch (e) {
// fall through to raw trimmed string below
}
}
return s;
}
/** Deletes all unused columns to the right of the header row. Cheap no-op once trimmed. */
function trimUnusedColumns_(sheet, usedColumnCount) {
var maxCols = sheet.getMaxColumns();
if (maxCols > usedColumnCount) {
sheet.deleteColumns(usedColumnCount + 1, maxCols - usedColumnCount);
}
}
/**
* Applies a yyyy-mm-dd number format to any header column whose name ends
* in "Date". IMPORTANT: unlike v1, this is now only called once at sheet
* creation time (from getOrCreateSheet_), not on every write — reformatting
* the whole column on every single call was a meaningful chunk of the
* per-run slowdown as sheets grew.
*/
function formatDateColumns_(sheet, headers) {
headers.forEach(function (h, i) {
if (/Date$/.test(h)) {
sheet.getRange(2, i + 1, Math.max(sheet.getMaxRows() - 1, 1), 1).setNumberFormat('yyyy-mm-dd');
}
});
}
/**
* Gets a sheet by name, creating it (with header row + date formatting)
* if it doesn't exist. Trims unused columns every call (cheap), but only
* formats date columns at creation time.
*/
function getOrCreateSheet_(name, headers) {
var ss = getSpreadsheet_();
var sheet = ss.getSheetByName(name);
var isNew = false;
if (!sheet) {
sheet = ss.insertSheet(name);
isNew = true;
} else if (sheet.getLastRow() === 0) {
isNew = true;
}
if (isNew) {
sheet.appendRow(headers);
sheet.getRange(1, 1, 1, headers.length).setFontWeight('bold');
sheet.setFrozenRows(1);
formatDateColumns_(sheet, headers);
}
trimUnusedColumns_(sheet, headers.length);
return sheet;
}
/**
* Builds a map of "key -> row number" for a sheet, based on one or more
* key columns (1-indexed). This is the expensive full-sheet read — call
* it ONCE per sheet per run (via upsertRows_/flush, never per repo).
*/
function buildRowIndex_(sheet, keyColumns) {
var index = {};
var lastRow = sheet.getLastRow();
if (lastRow < 2) return index;
var lastCol = Math.max.apply(null, keyColumns);
var values = sheet.getRange(2, 1, lastRow - 1, lastCol).getValues();
for (var i = 0; i < values.length; i++) {
var keyParts = keyColumns.map(function (c) { return normalizeKeyValue_(values[i][c - 1]); });
index[keyParts.join('||')] = i + 2;
}
return index;
}
/**
* Upserts many rows at once against a sheet, keyed by keyColumns
* (1-indexed column numbers whose values form the unique key).
* Pass ALL rows for ALL repos in one call — see section 10.
*/
function upsertRows_(sheet, rows, keyColumns) {
if (!rows.length) return;
var index = buildRowIndex_(sheet, keyColumns);
var toAppend = [];
rows.forEach(function (row) {
var keyParts = keyColumns.map(function (c) { return normalizeKeyValue_(row[c - 1]); });
var key = keyParts.join('||');
if (index[key]) {
sheet.getRange(index[key], 1, 1, row.length).setValues([row]);
} else {
toAppend.push(row);
index[key] = -1; // guard against duplicate keys within the same batch
}
});
if (toAppend.length) {
sheet.getRange(sheet.getLastRow() + 1, 1, toAppend.length, toAppend[0].length).setValues(toAppend);
}
}
/** Pure append (used for tables where we intentionally keep full history). */
function appendRows_(sheet, rows) {
if (!rows.length) return;
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length).setValues(rows);
}
// ============================================================
// 4. LOGGING (FROM: Logging.gs)
// ============================================================
/**
* Writes to the "Errors" and "ExecutionLog" sheets. Errors are buffered
* in memory and flushed in one batch write.
*/
var _errorBuffer = [];
function logError_(repoFullName, endpoint, httpStatus, message, retryAttempted) {
_errorBuffer.push([
new Date(),
repoFullName || '',
endpoint || '',
httpStatus || '',
message || '',
retryAttempted ? 'yes' : 'no'
]);
Logger.log('[ERROR] repo=%s endpoint=%s status=%s msg=%s', repoFullName, endpoint, httpStatus, message);
}
function flushErrorLog_() {
if (!_errorBuffer.length) return;
var sheet = getOrCreateSheet_('Errors', [
'Timestamp', 'Repository', 'Endpoint', 'HTTP Status', 'Message', 'Retry Attempted'
]);
sheet.getRange(sheet.getLastRow() + 1, 1, _errorBuffer.length, 6).setValues(_errorBuffer);
_errorBuffer = [];
}
function logExecution_(summary) {
var sheet = getOrCreateSheet_('ExecutionLog', [
'Start Time', 'End Time', 'Duration (s)', 'Repositories Processed',
'Repositories Failed', 'API Calls Made', 'Rate Limit Remaining', 'Notes'
]);
sheet.appendRow([
summary.startTime,
summary.endTime,
Math.round((summary.endTime - summary.startTime) / 1000),
summary.repositoriesProcessed,
summary.repositoriesFailed,
summary.apiCallsMade,
summary.rateLimitRemaining,
summary.notes || ''
]);
}
// ============================================================
// 5. REPOSITORY DISCOVERY (FROM: RepoDiscovery.gs)
// ============================================================
/**
* Uses GraphQL to discover every repository accessible to the
* authenticated user and pull metadata + language breakdown in as few
* API calls as possible. REST is reserved for traffic, contributors,
* referrers/paths, releases, and branches — endpoints GraphQL doesn't expose.
*/
var REPO_DISCOVERY_QUERY =
'query($cursor: String) {\n' +
' viewer {\n' +
' repositories(first: 50, after: $cursor, affiliations: [OWNER, ORGANIZATION_MEMBER, COLLABORATOR], ownerAffiliations: [OWNER, ORGANIZATION_MEMBER, COLLABORATOR], orderBy: {field: UPDATED_AT, direction: DESC}) {\n' +
' pageInfo { hasNextPage endCursor }\n' +
' nodes {\n' +
' databaseId\n' +
' name\n' +
' nameWithOwner\n' +
' owner { login }\n' +
' isPrivate\n' +
' isArchived\n' +
' isDisabled\n' +
' isFork\n' +
' parent { nameWithOwner }\n' +
' defaultBranchRef { name }\n' +
' createdAt\n' +
' updatedAt\n' +
' pushedAt\n' +
' diskUsage\n' +
' primaryLanguage { name }\n' +
' description\n' +
' homepageUrl\n' +
' licenseInfo { spdxId }\n' +
' repositoryTopics(first: 20) { nodes { topic { name } } }\n' +
' issues(states: OPEN) { totalCount }\n' +
' pullRequests(states: OPEN) { totalCount }\n' +
' forkCount\n' +
' stargazerCount\n' +
' watchers { totalCount }\n' +
' languages(first: 20, orderBy: {field: SIZE, direction: DESC}) {\n' +
' edges { size node { name } }\n' +
' }\n' +
' latestRelease { tagName publishedAt }\n' +
' releases { totalCount }\n' +
' }\n' +
' }\n' +
' }\n' +
' rateLimit { remaining resetAt }\n' +
'}';
function discoverRepositories(config) {
var all = [];
var cursor = null;
var hasNextPage = true;
var guard = 0;
while (hasNextPage && guard < 40) { // 40*50 = 2000 repos ceiling per run, safety guard
guard++;
var data = ghGraphQL_(REPO_DISCOVERY_QUERY, { cursor: cursor });
if (!data || !data.data || !data.data.viewer) {
logError_('', 'graphql:repositories', 0, 'No data returned from repository discovery query', false);
break;
}
var conn = data.data.viewer.repositories;
conn.nodes.forEach(function (n) { all.push(normalizeRepoNode_(n)); });
hasNextPage = conn.pageInfo.hasNextPage;
cursor = conn.pageInfo.endCursor;
// GraphQL's own rateLimit block is available here (data.data.rateLimit)
// but we rely on HTTP response headers (updateRateLimitState_) for
// throttling decisions instead, since that's populated for every call
// including REST ones — this field is fetched mainly for visibility
// if you want to inspect it in Stackdriver logs.
if (data.data.rateLimit) {
Logger.log('GraphQL rateLimit: remaining=%s resetAt=%s', data.data.rateLimit.remaining, data.data.rateLimit.resetAt);
}
}
var filtered = all.filter(function (r) {
if (!configBool_(config, 'INCLUDE_ARCHIVED') && r.archived) return false;
if (!configBool_(config, 'INCLUDE_FORKS') && r.fork) return false;
return true;
});
return filtered;
}
function normalizeRepoNode_(n) {
var topics = (n.repositoryTopics && n.repositoryTopics.nodes || [])
.map(function (t) { return t.topic.name; }).join(', ');
var languages = (n.languages && n.languages.edges || []).map(function (e) {
return { name: e.node.name, bytes: e.size };
});
return {
repoId: n.databaseId,
owner: n.owner.login,
name: n.name,
fullName: n.nameWithOwner,
visibility: n.isPrivate ? 'private' : 'public',
isPrivate: n.isPrivate,
defaultBranch: n.defaultBranchRef ? n.defaultBranchRef.name : '',
archived: n.isArchived,
disabled: n.isDisabled,
fork: n.isFork,
parentFullName: n.parent ? n.parent.nameWithOwner : '',
createdAt: n.createdAt,
updatedAt: n.updatedAt,
pushedAt: n.pushedAt,
sizeKb: n.diskUsage,
primaryLanguage: n.primaryLanguage ? n.primaryLanguage.name : '',
topics: topics,
license: n.licenseInfo ? n.licenseInfo.spdxId : '',
homepage: n.homepageUrl || '',
description: n.description || '',
openIssues: n.issues ? n.issues.totalCount : 0,
openPullRequests: n.pullRequests ? n.pullRequests.totalCount : 0,
forkCount: n.forkCount,
starCount: n.stargazerCount,
// GraphQL's `watchers` connection = REST's true subscriber count (people
// who clicked "Watch"), NOT the legacy REST `watchers_count` field
// (which is actually a star-count alias, and NOT the same thing as
// GitHub's "network" of forks-of-forks). We only keep ONE clearly
// labeled field for this to avoid the v1 bug of writing the same
// number into two differently-named, confusing columns.
subscriberCount: n.watchers ? n.watchers.totalCount : 0,
// GraphQL has no true "network size" field (forks-of-forks etc.) — this
// is an approximation using direct fork count only. Treat as indicative,
// not exact; the sheet column header says so explicitly.
networkCountApprox: n.forkCount,
languages: languages,
latestReleaseTag: n.latestRelease ? n.latestRelease.tagName : '',
latestReleaseDate: n.latestRelease ? n.latestRelease.publishedAt : '',
releaseCount: n.releases ? n.releases.totalCount : 0
};
}
// ============================================================
// 6. ACTIVITY SUMMARY (FROM: ActivitySummary.gs)
// ============================================================
/**
* Per-repository GraphQL query for closed/merged PR counts, draft PR
* count, closed issue count, and the latest commit timestamp.
*/
var ACTIVITY_QUERY =
'query($owner: String!, $name: String!, $draftQuery: String!) {\n' +
' repository(owner: $owner, name: $name) {\n' +
' openIssues: issues(states: OPEN) { totalCount }\n' +
' closedIssues: issues(states: CLOSED) { totalCount }\n' +
' openPRs: pullRequests(states: OPEN) { totalCount }\n' +
' closedPRs: pullRequests(states: CLOSED) { totalCount }\n' +
' mergedPRs: pullRequests(states: MERGED) { totalCount }\n' +
' defaultBranchRef {\n' +
' target {\n' +
' ... on Commit { committedDate }\n' +
' }\n' +
' }\n' +
' }\n' +
' draftSearch: search(query: $draftQuery, type: ISSUE) { issueCount }\n' +
'}';
function buildActivityVariables_(repo) {
return {
owner: repo.owner,
name: repo.name,
draftQuery: 'repo:' + repo.fullName + ' is:pr is:draft'
};
}
function collectActivitySummary(repo, config) {
var pauseMs = Number(config.ACTIVITY_QUERY_PAUSE_MS || 150);
if (pauseMs > 0) Utilities.sleep(pauseMs);
var data = ghGraphQL_(ACTIVITY_QUERY, buildActivityVariables_(repo), { repoFullName: repo.fullName, config: config });
if (!data || !data.data || !data.data.repository) {
return {
openIssues: repo.openIssues || 0, closedIssues: 0,
openPRs: repo.openPullRequests || 0, closedPRs: 0, mergedPRs: 0, draftPRs: 0,
latestCommitDate: ''
};
}
var r = data.data.repository;
return {
openIssues: r.openIssues.totalCount,
closedIssues: r.closedIssues.totalCount,
openPRs: r.openPRs.totalCount,
closedPRs: r.closedPRs.totalCount,
mergedPRs: r.mergedPRs.totalCount,
draftPRs: data.data.draftSearch ? data.data.draftSearch.issueCount : 0,
latestCommitDate: r.defaultBranchRef && r.defaultBranchRef.target ? r.defaultBranchRef.target.committedDate : ''
};
}
// ============================================================
// 7. TRAFFIC COLLECTOR (FROM: TrafficCollector.gs)
// ============================================================
/**
* REST-only endpoints: traffic views, clones, popular referrers/paths.
* GitHub only returns the last 14 days for all of these, so views/clones
* are UPSERTed by (repo, date). Referrers/paths are GitHub's own rolling
* top-10-of-last-14-days snapshot (not a daily-delta time series) — see
* REFERRERS_PATHS_FREQUENCY_DAYS in Config for how often we bother
* re-collecting that, and pruneOldRollingSnapshots_ (section 10) for how
* old rows get cleared out so that sheet doesn't grow forever.
*/
function collectTraffic(repo, config) {
var result = { views: [], clones: [], referrers: [], paths: [], unavailable: false };
var owner = repo.owner, name = repo.name;
var viewsRes = ghFetch_(GITHUB_API_ROOT + '/repos/' + owner + '/' + name + '/traffic/views', { repoFullName: repo.fullName, config: config });
if (viewsRes.status === 403) {
result.unavailable = true;
return result;
}
if (viewsRes.status === 200 && viewsRes.body) {
(viewsRes.body.views || []).forEach(function (v) {
result.views.push({ date: v.timestamp.substring(0, 10), count: v.count, uniques: v.uniques });
});
}
var clonesRes = ghFetch_(GITHUB_API_ROOT + '/repos/' + owner + '/' + name + '/traffic/clones', { repoFullName: repo.fullName, config: config });
if (clonesRes.status === 200 && clonesRes.body) {
(clonesRes.body.clones || []).forEach(function (c) {
result.clones.push({ date: c.timestamp.substring(0, 10), count: c.count, uniques: c.uniques });
});
}
var collectReferrersPaths = configBool_(config, 'COLLECT_REFERRERS_PATHS') &&
shouldRunOnFrequency_(config, 'REFERRERS_PATHS_FREQUENCY_DAYS', 7);
if (collectReferrersPaths) {
var refRes = ghFetch_(GITHUB_API_ROOT + '/repos/' + owner + '/' + name + '/traffic/popular/referrers', { repoFullName: repo.fullName, config: config });
if (refRes.status === 200 && Array.isArray(refRes.body)) {
refRes.body.forEach(function (r) {
result.referrers.push({ referrer: r.referrer, uniques: r.uniques, count: r.count });
});
}
var pathsRes = ghFetch_(GITHUB_API_ROOT + '/repos/' + owner + '/' + name + '/traffic/popular/paths', { repoFullName: repo.fullName, config: config });
if (pathsRes.status === 200 && Array.isArray(pathsRes.body)) {
pathsRes.body.forEach(function (p) {
result.paths.push({ path: p.path, title: p.title, count: p.count, uniques: p.uniques });
});
}
}
return result;
}
// ============================================================
// 8. CONTRIBUTORS COLLECTOR (FROM: ContributorsCollector.gs)
// ============================================================
/**
* GET /repos/{owner}/{repo}/contributors — paginated REST endpoint.
* NOTE: GitHub caches this and it can lag real activity. It's also
* sorted by commits on the default branch only, and caps around 500
* contributors — contributors who only appear via squash-merged PRs
* under a different default-branch author, or on non-default branches,
* may not show up here. Treat as indicative, not exhaustive.
*/
function collectContributors(repo, config) {
if (!configBool_(config, 'COLLECT_CONTRIBUTORS')) return [];
if (!shouldRunOnFrequency_(config, 'CONTRIBUTORS_FREQUENCY_DAYS', 1)) return [];
var owner = repo.owner, name = repo.name;
var items = ghGetPaginated_('/repos/' + owner + '/' + name + '/contributors', { anon: 0 }, { repoFullName: repo.fullName, config: config, maxPages: 10 });
return items.map(function (c) {
return { login: c.login || '(deleted user)', contributions: c.contributions };
});
}
// ============================================================
// 9. BRANCHES COLLECTOR (FROM: BranchesAndReleases.gs)
// ============================================================
/**
* Branches: GET /repos/{owner}/{repo}/branches (paginated), including
* protection status (REST v2022-11-28 list endpoint includes `protected`
* directly, no extra per-branch call needed).
*
* Releases: already captured cheaply via GraphQL in RepoDiscovery — no
* extra API calls needed here.
*/
function collectBranches(repo, config) {
if (!configBool_(config, 'COLLECT_BRANCHES')) return [];
if (!shouldRunOnFrequency_(config, 'BRANCHES_FREQUENCY_DAYS', 1)) return [];
var owner = repo.owner, name = repo.name;
var items = ghGetPaginated_('/repos/' + owner + '/' + name + '/branches', {}, { repoFullName: repo.fullName, config: config, maxPages: 10 });
return items.map(function (b) {
return {
name: b.name,
protected: !!b.protected,
lastCommitSha: b.commit ? b.commit.sha : ''
};
});
}
// ============================================================
// 10. STAGING / SHEET WRITERS (batch accumulation + flush)
// ============================================================
/**
* This is the core of the performance fix. In v1, every write*ToSheet_()
* function ran an upsert immediately, per repo — which meant re-reading
* the entire, ever-growing sheet history once per repo per run. Here,
* stage*_() functions just push plain row-arrays into an in-memory batch.
* flushBatch_() then does exactly ONE upsert/append pass per sheet,
* covering every repo processed so far.
*
* main() calls flushBatch_() periodically (every BATCH_FLUSH_EVERY_N_REPOS
* repos) as well as at the end, so a mid-run script kill doesn't lose
* everything staged since the run started — only what's staged since the
* last periodic flush.
*/
var REPOSITORIES_HEADERS = [
'Repository ID', 'Owner', 'Repository Name', 'Full Name', 'Visibility',
'Default Branch', 'Archived', 'Disabled', 'Fork', 'Parent Repository',
'Created Date', 'Updated Date', 'Last Push Date', 'Size (KB)', 'Primary Language',
'Topics', 'License', 'Homepage', 'Description',
'Open Issues', 'Closed Issues', 'Open Pull Requests', 'Closed Pull Requests',
'Merged Pull Requests', 'Draft Pull Requests',
'Fork Count', 'Star Count', 'Subscriber Count (true watchers)', 'Network Count (approx., = fork count)',
'Latest Release Tag', 'Latest Release Date', 'Release Count',
'Latest Commit Date', 'Last Collected At'
];
function createBatch_() {
return {
repositories: [],
languages: [],
snapshots: [],
trafficViews: [],
trafficClones: [],
referrers: [],
paths: [],
contributors: [],
branches: []
};
}
function stageRepositoryMetadata_(batch, repo, activity, now) {
batch.repositories.push([