feat: localize star history
This commit is contained in:
parent
96096ea0ff
commit
4ebb2ed6e9
|
|
@ -0,0 +1,29 @@
|
|||
# Third-party notices
|
||||
|
||||
## Star History logo icon
|
||||
|
||||
The self-contained Star History SVG renderer includes `logo-icon.png` from
|
||||
[star-history/star-history](https://github.com/star-history/star-history), used
|
||||
as the `star-history.com` watermark.
|
||||
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2025 Star History
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -0,0 +1,866 @@
|
|||
{
|
||||
"ongoing_interval_days": 13,
|
||||
"reconstruction": {
|
||||
"daily": [
|
||||
{
|
||||
"date": "2025-11-28",
|
||||
"stars": 1
|
||||
},
|
||||
{
|
||||
"date": "2025-12-22",
|
||||
"stars": 40
|
||||
},
|
||||
{
|
||||
"date": "2025-12-23",
|
||||
"stars": 270
|
||||
},
|
||||
{
|
||||
"date": "2025-12-24",
|
||||
"stars": 378
|
||||
},
|
||||
{
|
||||
"date": "2025-12-25",
|
||||
"stars": 430
|
||||
},
|
||||
{
|
||||
"date": "2025-12-26",
|
||||
"stars": 477
|
||||
},
|
||||
{
|
||||
"date": "2025-12-27",
|
||||
"stars": 497
|
||||
},
|
||||
{
|
||||
"date": "2025-12-28",
|
||||
"stars": 525
|
||||
},
|
||||
{
|
||||
"date": "2025-12-29",
|
||||
"stars": 577
|
||||
},
|
||||
{
|
||||
"date": "2025-12-30",
|
||||
"stars": 611
|
||||
},
|
||||
{
|
||||
"date": "2025-12-31",
|
||||
"stars": 646
|
||||
},
|
||||
{
|
||||
"date": "2026-01-01",
|
||||
"stars": 683
|
||||
},
|
||||
{
|
||||
"date": "2026-01-02",
|
||||
"stars": 719
|
||||
},
|
||||
{
|
||||
"date": "2026-01-03",
|
||||
"stars": 753
|
||||
},
|
||||
{
|
||||
"date": "2026-01-04",
|
||||
"stars": 798
|
||||
},
|
||||
{
|
||||
"date": "2026-01-05",
|
||||
"stars": 838
|
||||
},
|
||||
{
|
||||
"date": "2026-01-06",
|
||||
"stars": 894
|
||||
},
|
||||
{
|
||||
"date": "2026-01-07",
|
||||
"stars": 955
|
||||
},
|
||||
{
|
||||
"date": "2026-01-08",
|
||||
"stars": 1026
|
||||
},
|
||||
{
|
||||
"date": "2026-01-09",
|
||||
"stars": 1073
|
||||
},
|
||||
{
|
||||
"date": "2026-01-10",
|
||||
"stars": 1085
|
||||
},
|
||||
{
|
||||
"date": "2026-01-11",
|
||||
"stars": 1101
|
||||
},
|
||||
{
|
||||
"date": "2026-01-12",
|
||||
"stars": 1125
|
||||
},
|
||||
{
|
||||
"date": "2026-01-13",
|
||||
"stars": 1455
|
||||
},
|
||||
{
|
||||
"date": "2026-01-14",
|
||||
"stars": 1768
|
||||
},
|
||||
{
|
||||
"date": "2026-01-15",
|
||||
"stars": 1955
|
||||
},
|
||||
{
|
||||
"date": "2026-01-16",
|
||||
"stars": 2060
|
||||
},
|
||||
{
|
||||
"date": "2026-01-17",
|
||||
"stars": 2082
|
||||
},
|
||||
{
|
||||
"date": "2026-01-18",
|
||||
"stars": 2109
|
||||
},
|
||||
{
|
||||
"date": "2026-01-19",
|
||||
"stars": 2162
|
||||
},
|
||||
{
|
||||
"date": "2026-01-20",
|
||||
"stars": 2341
|
||||
},
|
||||
{
|
||||
"date": "2026-01-21",
|
||||
"stars": 2604
|
||||
},
|
||||
{
|
||||
"date": "2026-01-22",
|
||||
"stars": 2735
|
||||
},
|
||||
{
|
||||
"date": "2026-01-23",
|
||||
"stars": 2829
|
||||
},
|
||||
{
|
||||
"date": "2026-01-24",
|
||||
"stars": 2900
|
||||
},
|
||||
{
|
||||
"date": "2026-01-25",
|
||||
"stars": 2958
|
||||
},
|
||||
{
|
||||
"date": "2026-01-26",
|
||||
"stars": 2999
|
||||
},
|
||||
{
|
||||
"date": "2026-01-27",
|
||||
"stars": 3076
|
||||
},
|
||||
{
|
||||
"date": "2026-01-28",
|
||||
"stars": 3114
|
||||
},
|
||||
{
|
||||
"date": "2026-01-29",
|
||||
"stars": 3158
|
||||
},
|
||||
{
|
||||
"date": "2026-01-30",
|
||||
"stars": 3212
|
||||
},
|
||||
{
|
||||
"date": "2026-01-31",
|
||||
"stars": 3234
|
||||
},
|
||||
{
|
||||
"date": "2026-02-01",
|
||||
"stars": 3253
|
||||
},
|
||||
{
|
||||
"date": "2026-02-02",
|
||||
"stars": 3283
|
||||
},
|
||||
{
|
||||
"date": "2026-02-03",
|
||||
"stars": 3327
|
||||
},
|
||||
{
|
||||
"date": "2026-02-04",
|
||||
"stars": 3367
|
||||
},
|
||||
{
|
||||
"date": "2026-02-05",
|
||||
"stars": 3406
|
||||
},
|
||||
{
|
||||
"date": "2026-02-06",
|
||||
"stars": 3435
|
||||
},
|
||||
{
|
||||
"date": "2026-02-07",
|
||||
"stars": 3467
|
||||
},
|
||||
{
|
||||
"date": "2026-02-08",
|
||||
"stars": 3503
|
||||
},
|
||||
{
|
||||
"date": "2026-02-09",
|
||||
"stars": 3527
|
||||
},
|
||||
{
|
||||
"date": "2026-02-10",
|
||||
"stars": 3551
|
||||
},
|
||||
{
|
||||
"date": "2026-02-11",
|
||||
"stars": 3579
|
||||
},
|
||||
{
|
||||
"date": "2026-02-12",
|
||||
"stars": 3606
|
||||
},
|
||||
{
|
||||
"date": "2026-02-13",
|
||||
"stars": 3634
|
||||
},
|
||||
{
|
||||
"date": "2026-02-14",
|
||||
"stars": 3657
|
||||
},
|
||||
{
|
||||
"date": "2026-02-15",
|
||||
"stars": 3673
|
||||
},
|
||||
{
|
||||
"date": "2026-02-16",
|
||||
"stars": 3703
|
||||
},
|
||||
{
|
||||
"date": "2026-02-17",
|
||||
"stars": 3732
|
||||
},
|
||||
{
|
||||
"date": "2026-02-18",
|
||||
"stars": 3752
|
||||
},
|
||||
{
|
||||
"date": "2026-02-19",
|
||||
"stars": 3834
|
||||
},
|
||||
{
|
||||
"date": "2026-02-20",
|
||||
"stars": 3912
|
||||
},
|
||||
{
|
||||
"date": "2026-02-21",
|
||||
"stars": 3931
|
||||
},
|
||||
{
|
||||
"date": "2026-02-22",
|
||||
"stars": 3941
|
||||
},
|
||||
{
|
||||
"date": "2026-02-23",
|
||||
"stars": 3954
|
||||
},
|
||||
{
|
||||
"date": "2026-02-24",
|
||||
"stars": 3973
|
||||
},
|
||||
{
|
||||
"date": "2026-02-25",
|
||||
"stars": 3989
|
||||
},
|
||||
{
|
||||
"date": "2026-02-26",
|
||||
"stars": 4002
|
||||
},
|
||||
{
|
||||
"date": "2026-02-27",
|
||||
"stars": 4020
|
||||
},
|
||||
{
|
||||
"date": "2026-02-28",
|
||||
"stars": 4042
|
||||
},
|
||||
{
|
||||
"date": "2026-03-01",
|
||||
"stars": 4058
|
||||
},
|
||||
{
|
||||
"date": "2026-03-02",
|
||||
"stars": 4085
|
||||
},
|
||||
{
|
||||
"date": "2026-03-03",
|
||||
"stars": 4109
|
||||
},
|
||||
{
|
||||
"date": "2026-03-04",
|
||||
"stars": 4128
|
||||
},
|
||||
{
|
||||
"date": "2026-03-05",
|
||||
"stars": 4248
|
||||
},
|
||||
{
|
||||
"date": "2026-03-06",
|
||||
"stars": 4695
|
||||
},
|
||||
{
|
||||
"date": "2026-03-07",
|
||||
"stars": 5336
|
||||
},
|
||||
{
|
||||
"date": "2026-03-08",
|
||||
"stars": 6575
|
||||
},
|
||||
{
|
||||
"date": "2026-03-09",
|
||||
"stars": 10278
|
||||
},
|
||||
{
|
||||
"date": "2026-03-10",
|
||||
"stars": 13543
|
||||
},
|
||||
{
|
||||
"date": "2026-03-11",
|
||||
"stars": 16122
|
||||
},
|
||||
{
|
||||
"date": "2026-03-12",
|
||||
"stars": 18426
|
||||
},
|
||||
{
|
||||
"date": "2026-03-13",
|
||||
"stars": 21121
|
||||
},
|
||||
{
|
||||
"date": "2026-03-14",
|
||||
"stars": 23302
|
||||
},
|
||||
{
|
||||
"date": "2026-03-15",
|
||||
"stars": 26299
|
||||
},
|
||||
{
|
||||
"date": "2026-03-16",
|
||||
"stars": 29077
|
||||
},
|
||||
{
|
||||
"date": "2026-03-17",
|
||||
"stars": 31779
|
||||
},
|
||||
{
|
||||
"date": "2026-03-18",
|
||||
"stars": 33459
|
||||
},
|
||||
{
|
||||
"date": "2026-03-19",
|
||||
"stars": 34942
|
||||
},
|
||||
{
|
||||
"date": "2026-03-20",
|
||||
"stars": 36279
|
||||
},
|
||||
{
|
||||
"date": "2026-03-21",
|
||||
"stars": 37440
|
||||
},
|
||||
{
|
||||
"date": "2026-03-22",
|
||||
"stars": 38779
|
||||
},
|
||||
{
|
||||
"date": "2026-03-23",
|
||||
"stars": 39941
|
||||
},
|
||||
{
|
||||
"date": "2026-03-24",
|
||||
"stars": 40913
|
||||
},
|
||||
{
|
||||
"date": "2026-03-25",
|
||||
"stars": 41761
|
||||
},
|
||||
{
|
||||
"date": "2026-03-26",
|
||||
"stars": 42672
|
||||
},
|
||||
{
|
||||
"date": "2026-03-27",
|
||||
"stars": 43370
|
||||
},
|
||||
{
|
||||
"date": "2026-03-28",
|
||||
"stars": 43999
|
||||
},
|
||||
{
|
||||
"date": "2026-03-29",
|
||||
"stars": 44630
|
||||
},
|
||||
{
|
||||
"date": "2026-03-30",
|
||||
"stars": 45425
|
||||
},
|
||||
{
|
||||
"date": "2026-03-31",
|
||||
"stars": 46118
|
||||
},
|
||||
{
|
||||
"date": "2026-04-01",
|
||||
"stars": 46740
|
||||
},
|
||||
{
|
||||
"date": "2026-04-02",
|
||||
"stars": 47421
|
||||
},
|
||||
{
|
||||
"date": "2026-04-03",
|
||||
"stars": 48061
|
||||
},
|
||||
{
|
||||
"date": "2026-04-04",
|
||||
"stars": 48517
|
||||
},
|
||||
{
|
||||
"date": "2026-04-05",
|
||||
"stars": 49170
|
||||
},
|
||||
{
|
||||
"date": "2026-04-06",
|
||||
"stars": 49863
|
||||
},
|
||||
{
|
||||
"date": "2026-04-07",
|
||||
"stars": 50461
|
||||
},
|
||||
{
|
||||
"date": "2026-04-08",
|
||||
"stars": 51175
|
||||
},
|
||||
{
|
||||
"date": "2026-04-09",
|
||||
"stars": 51931
|
||||
},
|
||||
{
|
||||
"date": "2026-04-10",
|
||||
"stars": 52444
|
||||
},
|
||||
{
|
||||
"date": "2026-04-11",
|
||||
"stars": 52970
|
||||
},
|
||||
{
|
||||
"date": "2026-04-12",
|
||||
"stars": 53419
|
||||
},
|
||||
{
|
||||
"date": "2026-04-13",
|
||||
"stars": 53936
|
||||
},
|
||||
{
|
||||
"date": "2026-04-14",
|
||||
"stars": 54326
|
||||
},
|
||||
{
|
||||
"date": "2026-04-15",
|
||||
"stars": 54652
|
||||
},
|
||||
{
|
||||
"date": "2026-04-16",
|
||||
"stars": 54949
|
||||
},
|
||||
{
|
||||
"date": "2026-04-17",
|
||||
"stars": 55207
|
||||
},
|
||||
{
|
||||
"date": "2026-04-18",
|
||||
"stars": 55386
|
||||
},
|
||||
{
|
||||
"date": "2026-04-19",
|
||||
"stars": 55574
|
||||
},
|
||||
{
|
||||
"date": "2026-04-20",
|
||||
"stars": 55783
|
||||
},
|
||||
{
|
||||
"date": "2026-04-21",
|
||||
"stars": 55988
|
||||
},
|
||||
{
|
||||
"date": "2026-04-22",
|
||||
"stars": 56203
|
||||
},
|
||||
{
|
||||
"date": "2026-04-23",
|
||||
"stars": 56364
|
||||
},
|
||||
{
|
||||
"date": "2026-04-24",
|
||||
"stars": 56530
|
||||
},
|
||||
{
|
||||
"date": "2026-04-25",
|
||||
"stars": 56689
|
||||
},
|
||||
{
|
||||
"date": "2026-04-26",
|
||||
"stars": 56921
|
||||
},
|
||||
{
|
||||
"date": "2026-04-27",
|
||||
"stars": 57161
|
||||
},
|
||||
{
|
||||
"date": "2026-04-28",
|
||||
"stars": 57400
|
||||
},
|
||||
{
|
||||
"date": "2026-04-29",
|
||||
"stars": 57658
|
||||
},
|
||||
{
|
||||
"date": "2026-04-30",
|
||||
"stars": 57873
|
||||
},
|
||||
{
|
||||
"date": "2026-05-01",
|
||||
"stars": 58048
|
||||
},
|
||||
{
|
||||
"date": "2026-05-02",
|
||||
"stars": 58213
|
||||
},
|
||||
{
|
||||
"date": "2026-05-03",
|
||||
"stars": 58386
|
||||
},
|
||||
{
|
||||
"date": "2026-05-04",
|
||||
"stars": 58522
|
||||
},
|
||||
{
|
||||
"date": "2026-05-05",
|
||||
"stars": 58673
|
||||
},
|
||||
{
|
||||
"date": "2026-05-06",
|
||||
"stars": 58820
|
||||
},
|
||||
{
|
||||
"date": "2026-05-07",
|
||||
"stars": 58977
|
||||
},
|
||||
{
|
||||
"date": "2026-05-08",
|
||||
"stars": 59115
|
||||
},
|
||||
{
|
||||
"date": "2026-05-09",
|
||||
"stars": 59239
|
||||
},
|
||||
{
|
||||
"date": "2026-05-10",
|
||||
"stars": 59417
|
||||
},
|
||||
{
|
||||
"date": "2026-05-11",
|
||||
"stars": 59622
|
||||
},
|
||||
{
|
||||
"date": "2026-05-12",
|
||||
"stars": 59811
|
||||
},
|
||||
{
|
||||
"date": "2026-05-13",
|
||||
"stars": 60008
|
||||
},
|
||||
{
|
||||
"date": "2026-05-14",
|
||||
"stars": 60186
|
||||
},
|
||||
{
|
||||
"date": "2026-05-15",
|
||||
"stars": 60318
|
||||
},
|
||||
{
|
||||
"date": "2026-05-16",
|
||||
"stars": 60431
|
||||
},
|
||||
{
|
||||
"date": "2026-05-17",
|
||||
"stars": 60563
|
||||
},
|
||||
{
|
||||
"date": "2026-05-18",
|
||||
"stars": 60695
|
||||
},
|
||||
{
|
||||
"date": "2026-05-19",
|
||||
"stars": 60807
|
||||
},
|
||||
{
|
||||
"date": "2026-05-20",
|
||||
"stars": 60921
|
||||
},
|
||||
{
|
||||
"date": "2026-05-21",
|
||||
"stars": 61024
|
||||
},
|
||||
{
|
||||
"date": "2026-05-22",
|
||||
"stars": 61118
|
||||
},
|
||||
{
|
||||
"date": "2026-05-23",
|
||||
"stars": 61320
|
||||
},
|
||||
{
|
||||
"date": "2026-05-24",
|
||||
"stars": 61680
|
||||
},
|
||||
{
|
||||
"date": "2026-05-25",
|
||||
"stars": 62078
|
||||
},
|
||||
{
|
||||
"date": "2026-05-26",
|
||||
"stars": 62214
|
||||
},
|
||||
{
|
||||
"date": "2026-05-27",
|
||||
"stars": 62411
|
||||
},
|
||||
{
|
||||
"date": "2026-05-28",
|
||||
"stars": 62613
|
||||
},
|
||||
{
|
||||
"date": "2026-05-29",
|
||||
"stars": 62761
|
||||
},
|
||||
{
|
||||
"date": "2026-05-30",
|
||||
"stars": 62909
|
||||
},
|
||||
{
|
||||
"date": "2026-05-31",
|
||||
"stars": 63036
|
||||
},
|
||||
{
|
||||
"date": "2026-06-01",
|
||||
"stars": 63165
|
||||
},
|
||||
{
|
||||
"date": "2026-06-02",
|
||||
"stars": 63305
|
||||
},
|
||||
{
|
||||
"date": "2026-06-03",
|
||||
"stars": 63519
|
||||
},
|
||||
{
|
||||
"date": "2026-06-04",
|
||||
"stars": 63957
|
||||
},
|
||||
{
|
||||
"date": "2026-06-05",
|
||||
"stars": 64352
|
||||
},
|
||||
{
|
||||
"date": "2026-06-06",
|
||||
"stars": 64705
|
||||
},
|
||||
{
|
||||
"date": "2026-06-07",
|
||||
"stars": 64892
|
||||
},
|
||||
{
|
||||
"date": "2026-06-08",
|
||||
"stars": 65095
|
||||
},
|
||||
{
|
||||
"date": "2026-06-09",
|
||||
"stars": 65280
|
||||
},
|
||||
{
|
||||
"date": "2026-06-10",
|
||||
"stars": 65552
|
||||
},
|
||||
{
|
||||
"date": "2026-06-11",
|
||||
"stars": 65807
|
||||
},
|
||||
{
|
||||
"date": "2026-06-12",
|
||||
"stars": 65963
|
||||
},
|
||||
{
|
||||
"date": "2026-06-13",
|
||||
"stars": 66069
|
||||
},
|
||||
{
|
||||
"date": "2026-06-14",
|
||||
"stars": 66175
|
||||
},
|
||||
{
|
||||
"date": "2026-06-15",
|
||||
"stars": 66280
|
||||
},
|
||||
{
|
||||
"date": "2026-06-16",
|
||||
"stars": 66358
|
||||
},
|
||||
{
|
||||
"date": "2026-06-17",
|
||||
"stars": 66457
|
||||
},
|
||||
{
|
||||
"date": "2026-06-18",
|
||||
"stars": 66537
|
||||
},
|
||||
{
|
||||
"date": "2026-06-19",
|
||||
"stars": 66594
|
||||
},
|
||||
{
|
||||
"date": "2026-06-20",
|
||||
"stars": 66640
|
||||
},
|
||||
{
|
||||
"date": "2026-06-21",
|
||||
"stars": 66703
|
||||
},
|
||||
{
|
||||
"date": "2026-06-22",
|
||||
"stars": 66792
|
||||
},
|
||||
{
|
||||
"date": "2026-06-23",
|
||||
"stars": 66863
|
||||
},
|
||||
{
|
||||
"date": "2026-06-24",
|
||||
"stars": 66943
|
||||
},
|
||||
{
|
||||
"date": "2026-06-25",
|
||||
"stars": 67021
|
||||
},
|
||||
{
|
||||
"date": "2026-06-26",
|
||||
"stars": 67107
|
||||
},
|
||||
{
|
||||
"date": "2026-06-27",
|
||||
"stars": 67199
|
||||
},
|
||||
{
|
||||
"date": "2026-06-28",
|
||||
"stars": 67276
|
||||
},
|
||||
{
|
||||
"date": "2026-06-29",
|
||||
"stars": 67351
|
||||
},
|
||||
{
|
||||
"date": "2026-06-30",
|
||||
"stars": 67430
|
||||
},
|
||||
{
|
||||
"date": "2026-07-01",
|
||||
"stars": 67497
|
||||
},
|
||||
{
|
||||
"date": "2026-07-02",
|
||||
"stars": 67586
|
||||
},
|
||||
{
|
||||
"date": "2026-07-03",
|
||||
"stars": 67664
|
||||
},
|
||||
{
|
||||
"date": "2026-07-04",
|
||||
"stars": 67720
|
||||
},
|
||||
{
|
||||
"date": "2026-07-05",
|
||||
"stars": 67775
|
||||
},
|
||||
{
|
||||
"date": "2026-07-06",
|
||||
"stars": 67842
|
||||
},
|
||||
{
|
||||
"date": "2026-07-07",
|
||||
"stars": 67986
|
||||
},
|
||||
{
|
||||
"date": "2026-07-08",
|
||||
"stars": 68088
|
||||
},
|
||||
{
|
||||
"date": "2026-07-09",
|
||||
"stars": 68192
|
||||
},
|
||||
{
|
||||
"date": "2026-07-10",
|
||||
"stars": 68262
|
||||
},
|
||||
{
|
||||
"date": "2026-07-11",
|
||||
"stars": 68330
|
||||
},
|
||||
{
|
||||
"date": "2026-07-12",
|
||||
"stars": 68384
|
||||
},
|
||||
{
|
||||
"date": "2026-07-13",
|
||||
"stars": 68443
|
||||
},
|
||||
{
|
||||
"date": "2026-07-14",
|
||||
"stars": 68512
|
||||
},
|
||||
{
|
||||
"date": "2026-07-15",
|
||||
"stars": 68582
|
||||
},
|
||||
{
|
||||
"date": "2026-07-16",
|
||||
"stars": 68640
|
||||
},
|
||||
{
|
||||
"date": "2026-07-17",
|
||||
"stars": 68714
|
||||
},
|
||||
{
|
||||
"date": "2026-07-18",
|
||||
"stars": 68766
|
||||
},
|
||||
{
|
||||
"date": "2026-07-19",
|
||||
"stars": 68901
|
||||
},
|
||||
{
|
||||
"date": "2026-07-20",
|
||||
"stars": 68993
|
||||
}
|
||||
],
|
||||
"generated_at": "2026-07-21T07:30:41Z",
|
||||
"method": "current_stargazers_starred_at"
|
||||
},
|
||||
"repository": "666ghj/MiroFish",
|
||||
"schema_version": 1,
|
||||
"snapshots": [
|
||||
{
|
||||
"at": "2026-07-21T07:46:01Z",
|
||||
"stars": 69026
|
||||
}
|
||||
],
|
||||
"timezone": "UTC"
|
||||
}
|
||||
|
|
@ -0,0 +1,322 @@
|
|||
name: Update Star History
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '17 3 1,16 * *'
|
||||
timezone: 'UTC'
|
||||
workflow_dispatch:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: repository-star-history-${{ github.repository_id }}
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
update-default-branch:
|
||||
if: >-
|
||||
${{
|
||||
github.repository == '666ghj/MiroFish' &&
|
||||
github.ref_name == github.event.repository.default_branch &&
|
||||
(
|
||||
github.event_name == 'schedule' ||
|
||||
(
|
||||
github.event_name == 'workflow_dispatch' &&
|
||||
github.actor_id == '110395318' &&
|
||||
github.triggering_actor == '666ghj'
|
||||
)
|
||||
)
|
||||
}}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 10
|
||||
permissions:
|
||||
contents: write
|
||||
env:
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
EXPECTED_REPOSITORY: '666ghj/MiroFish'
|
||||
EXPECTED_DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
|
||||
steps:
|
||||
- name: Fetch triggering public commit without credentials
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ''
|
||||
GH_TOKEN: ''
|
||||
run: |
|
||||
set -euo pipefail
|
||||
[[ "${GITHUB_REPOSITORY,,}" == "${EXPECTED_REPOSITORY,,}" ]]
|
||||
[[ "$GITHUB_REF" == "refs/heads/$EXPECTED_DEFAULT_BRANCH" ]]
|
||||
git init .
|
||||
git remote add origin 'https://github.com/666ghj/MiroFish.git'
|
||||
git \
|
||||
-c credential.helper= \
|
||||
-c http.followRedirects=false \
|
||||
fetch \
|
||||
--no-tags \
|
||||
--depth=1 \
|
||||
origin \
|
||||
"$GITHUB_REF"
|
||||
[[ "$(git rev-parse FETCH_HEAD)" == "$GITHUB_SHA" ]]
|
||||
git -c core.hooksPath=/dev/null checkout --detach "$GITHUB_SHA"
|
||||
[[ -z "$(git status --porcelain --untracked-files=all)" ]]
|
||||
|
||||
- name: Run Star History tests without tokens
|
||||
env:
|
||||
GITHUB_TOKEN: ''
|
||||
GH_TOKEN: ''
|
||||
run: >-
|
||||
python3 -m unittest
|
||||
tests.test_local_star_history
|
||||
tests.test_local_star_count_fetch
|
||||
-v
|
||||
|
||||
- name: Fetch aggregate Star count only
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
printf '%s %s\n' \
|
||||
'dfe9e0060d9abb0b3e1cda61bd73bba77fe878815adcbea14601666dce30e927' \
|
||||
'scripts/fetch_star_count.py' |
|
||||
sha256sum --check --strict -
|
||||
output="$RUNNER_TEMP/repository-star-count.txt"
|
||||
[[ ! -e "$output" && ! -L "$output" ]]
|
||||
python3 scripts/fetch_star_count.py > "$output"
|
||||
[[ -f "$output" && ! -L "$output" ]]
|
||||
(( $(wc -c < "$output") <= 32 ))
|
||||
mapfile -t lines < "$output"
|
||||
(( ${#lines[@]} == 1 ))
|
||||
[[ "${lines[0]}" =~ ^[0-9]+$ ]]
|
||||
|
||||
- name: Record scheduled aggregate Star snapshot offline without tokens
|
||||
shell: bash
|
||||
env:
|
||||
STAR_COUNT_FILE: ${{ runner.temp }}/repository-star-count.txt
|
||||
GITHUB_TOKEN: ''
|
||||
GH_TOKEN: ''
|
||||
run: |
|
||||
set -euo pipefail
|
||||
trap 'rm -f -- "$STAR_COUNT_FILE"' EXIT
|
||||
[[ -z "${GITHUB_TOKEN:-}" && -z "${GH_TOKEN:-}" ]]
|
||||
python3 scripts/star_history.py record \
|
||||
--count-file "$STAR_COUNT_FILE" \
|
||||
--force
|
||||
|
||||
- name: Verify generated outputs without tokens
|
||||
env:
|
||||
GITHUB_TOKEN: ''
|
||||
GH_TOKEN: ''
|
||||
run: |
|
||||
python3 scripts/star_history.py check
|
||||
python3 -m unittest \
|
||||
tests.test_local_star_history \
|
||||
tests.test_local_star_count_fetch \
|
||||
-v
|
||||
|
||||
- name: Commit exact output allowlist
|
||||
id: commit
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ''
|
||||
GH_TOKEN: ''
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
allowed() {
|
||||
case "$1" in
|
||||
.github/star-history/history.json|\
|
||||
static/image/star-history-light.svg|\
|
||||
static/image/star-history-dark.svg) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
bad=0
|
||||
while IFS= read -r -d '' path; do
|
||||
if ! allowed "$path"; then
|
||||
printf '::error::Unexpected changed path: %q\n' "$path"
|
||||
bad=1
|
||||
fi
|
||||
done < <(
|
||||
git diff --name-only -z
|
||||
git diff --cached --name-only -z
|
||||
git ls-files --others --exclude-standard -z
|
||||
)
|
||||
(( bad == 0 )) || exit 1
|
||||
|
||||
for path in \
|
||||
.github/star-history/history.json \
|
||||
static/image/star-history-light.svg \
|
||||
static/image/star-history-dark.svg
|
||||
do
|
||||
[[ -f "$path" && ! -L "$path" && -s "$path" ]] || {
|
||||
printf '::error::Invalid output file: %s\n' "$path"
|
||||
exit 1
|
||||
}
|
||||
[[ "$(realpath -e -- "$path")" == "$GITHUB_WORKSPACE/$path" ]] || {
|
||||
printf '::error::Output escaped workspace: %s\n' "$path"
|
||||
exit 1
|
||||
}
|
||||
done
|
||||
|
||||
git add -- \
|
||||
.github/star-history/history.json \
|
||||
static/image/star-history-light.svg \
|
||||
static/image/star-history-dark.svg
|
||||
|
||||
if git diff --cached --quiet; then
|
||||
printf 'created=false\n' >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
fi
|
||||
|
||||
count=0
|
||||
while IFS= read -r -d '' path; do
|
||||
allowed "$path" || exit 1
|
||||
((count += 1))
|
||||
done < <(git diff --cached --name-only -z)
|
||||
(( count > 0 )) || exit 1
|
||||
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git \
|
||||
-c commit.gpgsign=false \
|
||||
-c core.hooksPath=/dev/null \
|
||||
commit \
|
||||
-m 'chore: update star history [skip ci]'
|
||||
printf 'created=true\n' >> "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Verify one allowlisted commit and unchanged main target
|
||||
if: ${{ steps.commit.outputs.created == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ''
|
||||
GH_TOKEN: ''
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
allowed() {
|
||||
case "$1" in
|
||||
.github/star-history/history.json|\
|
||||
static/image/star-history-light.svg|\
|
||||
static/image/star-history-dark.svg) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
base="$GITHUB_SHA"
|
||||
target_ref="$GITHUB_REF"
|
||||
[[ "${GITHUB_REPOSITORY,,}" == "${EXPECTED_REPOSITORY,,}" ]]
|
||||
[[ "$GITHUB_REF" == "refs/heads/$EXPECTED_DEFAULT_BRANCH" ]]
|
||||
[[ "$(git rev-parse HEAD^)" == "$base" ]]
|
||||
[[ "$(git rev-list --count "${base}..HEAD")" == 1 ]]
|
||||
[[ -z "$(git status --porcelain --untracked-files=all)" ]]
|
||||
|
||||
origin="$(git remote get-url origin)"
|
||||
case "$origin" in
|
||||
https://github.com/666ghj/MiroFish|\
|
||||
https://github.com/666ghj/MiroFish.git) ;;
|
||||
*)
|
||||
echo "::error::Unexpected origin"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
mapfile -t push_urls < <(git remote get-url --push --all origin)
|
||||
(( ${#push_urls[@]} == 1 ))
|
||||
[[ "${push_urls[0]}" == "$origin" ]]
|
||||
|
||||
count=0
|
||||
while IFS= read -r -d '' path; do
|
||||
allowed "$path" || {
|
||||
printf '::error::Unexpected committed path: %q\n' "$path"
|
||||
exit 1
|
||||
}
|
||||
((count += 1))
|
||||
done < <(git diff-tree --no-commit-id --name-only -r -z HEAD)
|
||||
(( count > 0 )) || exit 1
|
||||
|
||||
git \
|
||||
-c credential.helper= \
|
||||
-c http.followRedirects=false \
|
||||
fetch \
|
||||
--no-tags \
|
||||
--depth=1 \
|
||||
origin \
|
||||
"$target_ref"
|
||||
[[ "$(git rev-parse FETCH_HEAD)" == "$base" ]] || {
|
||||
echo "::error::Target advanced; refusing to rebase or overwrite"
|
||||
exit 1
|
||||
}
|
||||
|
||||
- name: Push one allowlisted commit with an ephemeral credential
|
||||
if: ${{ steps.commit.outputs.created == 'true' }}
|
||||
shell: bash
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
GIT_TERMINAL_PROMPT: '0'
|
||||
GIT_TRACE: '0'
|
||||
GIT_TRACE_CURL: '0'
|
||||
GIT_TRACE_PACKET: '0'
|
||||
GIT_CURL_VERBOSE: '0'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
allowed() {
|
||||
case "$1" in
|
||||
.github/star-history/history.json|\
|
||||
static/image/star-history-light.svg|\
|
||||
static/image/star-history-dark.svg) return 0 ;;
|
||||
*) return 1 ;;
|
||||
esac
|
||||
}
|
||||
|
||||
base="$GITHUB_SHA"
|
||||
[[ "${GITHUB_REPOSITORY,,}" == "${EXPECTED_REPOSITORY,,}" ]]
|
||||
[[ "$GITHUB_REF" == "refs/heads/$EXPECTED_DEFAULT_BRANCH" ]]
|
||||
[[ "$(git rev-parse HEAD^)" == "$base" ]]
|
||||
[[ "$(git rev-list --count "${base}..HEAD")" == 1 ]]
|
||||
[[ -z "$(git status --porcelain --untracked-files=all)" ]]
|
||||
|
||||
origin="$(git remote get-url origin)"
|
||||
case "$origin" in
|
||||
https://github.com/666ghj/MiroFish|\
|
||||
https://github.com/666ghj/MiroFish.git) ;;
|
||||
*)
|
||||
echo "::error::Unexpected origin"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
mapfile -t push_urls < <(git remote get-url --push --all origin)
|
||||
(( ${#push_urls[@]} == 1 ))
|
||||
[[ "${push_urls[0]}" == "$origin" ]]
|
||||
|
||||
count=0
|
||||
while IFS= read -r -d '' path; do
|
||||
allowed "$path" || {
|
||||
printf '::error::Unexpected committed path: %q\n' "$path"
|
||||
exit 1
|
||||
}
|
||||
((count += 1))
|
||||
done < <(git diff-tree --no-commit-id --name-only -r -z HEAD)
|
||||
(( count > 0 )) || exit 1
|
||||
|
||||
[[ -n "$GITHUB_TOKEN" ]]
|
||||
[[ "$GITHUB_TOKEN" != *$'\n'* && "$GITHUB_TOKEN" != *$'\r'* ]]
|
||||
encoded="$(
|
||||
printf 'x-access-token:%s' "$GITHUB_TOKEN" |
|
||||
base64 |
|
||||
tr -d '\n'
|
||||
)"
|
||||
export GIT_CONFIG_COUNT=1
|
||||
export GIT_CONFIG_KEY_0="http.${origin}.extraheader"
|
||||
export GIT_CONFIG_VALUE_0="AUTHORIZATION: basic $encoded"
|
||||
unset encoded GITHUB_TOKEN
|
||||
trap 'unset GIT_CONFIG_COUNT GIT_CONFIG_KEY_0 GIT_CONFIG_VALUE_0' EXIT
|
||||
|
||||
git \
|
||||
-c core.hooksPath=/dev/null \
|
||||
-c credential.helper= \
|
||||
push \
|
||||
--porcelain \
|
||||
origin \
|
||||
"HEAD:$GITHUB_REF"
|
||||
|
|
@ -194,10 +194,10 @@ MiroFish 的仿真引擎由 **[OASIS](https://github.com/camel-ai/oasis)** 驱
|
|||
|
||||
## 📈 项目统计
|
||||
|
||||
<a href="https://www.star-history.com/#666ghj/MiroFish&type=date&legend=top-left">
|
||||
<a href="https://github.com/666ghj/MiroFish">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="static/image/star-history-dark.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="static/image/star-history-light.svg" />
|
||||
<img alt="666ghj/MiroFish Star History Chart" src="static/image/star-history-light.svg" />
|
||||
</picture>
|
||||
</a>
|
||||
|
|
|
|||
10
README.md
10
README.md
|
|
@ -194,10 +194,10 @@ MiroFish's simulation engine is powered by **[OASIS (Open Agent Social Interacti
|
|||
|
||||
## 📈 Project Statistics
|
||||
|
||||
<a href="https://www.star-history.com/#666ghj/MiroFish&type=date&legend=top-left">
|
||||
<a href="https://github.com/666ghj/MiroFish">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=666ghj/MiroFish&type=date&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: dark)" srcset="static/image/star-history-dark.svg" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="static/image/star-history-light.svg" />
|
||||
<img alt="666ghj/MiroFish Star History Chart" src="static/image/star-history-light.svg" />
|
||||
</picture>
|
||||
</a>
|
||||
</a>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,146 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Fetch one repository's aggregate GitHub Star count without loading the renderer.
|
||||
|
||||
The successful stdout contract is deliberately tiny: one non-negative decimal
|
||||
integer followed by a newline. Errors are fixed, sanitized messages on stderr.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from typing import Any
|
||||
|
||||
|
||||
REPOSITORY = "666ghj/MiroFish"
|
||||
API_URL = f"https://api.github.com/repos/{REPOSITORY}"
|
||||
API_VERSION = "2026-03-10"
|
||||
MAX_HTTP_BYTES = 1_000_000
|
||||
TIMEOUT_SECONDS = 20
|
||||
|
||||
|
||||
class FetchError(RuntimeError):
|
||||
"""A safe error whose message never includes response or secret data."""
|
||||
|
||||
|
||||
class NoRedirectHandler(urllib.request.HTTPRedirectHandler):
|
||||
"""Refuse every redirect so credentials cannot be forwarded elsewhere."""
|
||||
|
||||
def redirect_request(self, *_args: Any, **_kwargs: Any) -> None:
|
||||
return None
|
||||
|
||||
|
||||
def _build_opener() -> urllib.request.OpenerDirector:
|
||||
return urllib.request.build_opener(NoRedirectHandler())
|
||||
|
||||
|
||||
def _status_error(status: int) -> FetchError:
|
||||
if status in {301, 302, 303, 307, 308}:
|
||||
return FetchError("GitHub API redirect was refused")
|
||||
if status == 401:
|
||||
return FetchError("GitHub API authentication failed")
|
||||
if status == 403:
|
||||
return FetchError("GitHub API request was denied")
|
||||
if status == 404:
|
||||
return FetchError("repository metadata was not found")
|
||||
if status == 429:
|
||||
return FetchError("GitHub API rate limit was exhausted")
|
||||
if 500 <= status <= 599:
|
||||
return FetchError("GitHub API is unavailable")
|
||||
return FetchError("GitHub API request failed")
|
||||
|
||||
|
||||
def _read_response(response: Any) -> bytes:
|
||||
raw_length = response.headers.get("Content-Length")
|
||||
if raw_length is not None:
|
||||
try:
|
||||
content_length = int(raw_length, 10)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise FetchError("GitHub API returned invalid response metadata") from exc
|
||||
if content_length < 0 or content_length > MAX_HTTP_BYTES:
|
||||
raise FetchError("GitHub API response exceeded the size limit")
|
||||
|
||||
payload = response.read(MAX_HTTP_BYTES + 1)
|
||||
if len(payload) > MAX_HTTP_BYTES:
|
||||
raise FetchError("GitHub API response exceeded the size limit")
|
||||
return payload
|
||||
|
||||
|
||||
def fetch_star_count(token: str, opener: Any | None = None) -> int:
|
||||
if not token or len(token) > 4_096 or "\r" in token or "\n" in token:
|
||||
raise FetchError("GITHUB_TOKEN is missing or invalid")
|
||||
|
||||
request = urllib.request.Request(
|
||||
API_URL,
|
||||
headers={
|
||||
"Accept": "application/vnd.github+json",
|
||||
"Authorization": f"Bearer {token}",
|
||||
"User-Agent": "Repository-Star-History-Fetcher",
|
||||
"X-GitHub-Api-Version": API_VERSION,
|
||||
},
|
||||
method="GET",
|
||||
)
|
||||
client = opener or _build_opener()
|
||||
try:
|
||||
response = client.open(request, timeout=TIMEOUT_SECONDS)
|
||||
except urllib.error.HTTPError as exc:
|
||||
status = exc.code
|
||||
exc.close()
|
||||
raise _status_error(status) from None
|
||||
except (urllib.error.URLError, TimeoutError, OSError):
|
||||
raise FetchError("GitHub API network request failed") from None
|
||||
except Exception:
|
||||
raise FetchError("GitHub API request could not be started") from None
|
||||
|
||||
try:
|
||||
with response:
|
||||
if response.geturl() != API_URL:
|
||||
raise FetchError("GitHub API redirect was refused")
|
||||
status = response.getcode()
|
||||
if status != 200:
|
||||
raise _status_error(status)
|
||||
payload = _read_response(response)
|
||||
except FetchError:
|
||||
raise
|
||||
except (TimeoutError, OSError):
|
||||
raise FetchError("GitHub API response could not be read") from None
|
||||
except Exception:
|
||||
raise FetchError("GitHub API response could not be processed") from None
|
||||
|
||||
try:
|
||||
document = json.loads(payload)
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, ValueError):
|
||||
raise FetchError("GitHub API returned malformed JSON") from None
|
||||
if not isinstance(document, dict):
|
||||
raise FetchError("GitHub API response had an unexpected shape")
|
||||
|
||||
count = document.get("stargazers_count")
|
||||
if type(count) is not int or count < 0:
|
||||
raise FetchError("GitHub API returned an invalid stargazers_count")
|
||||
return count
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
arguments = sys.argv[1:] if argv is None else argv
|
||||
if arguments:
|
||||
print("error: this command accepts no arguments", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
try:
|
||||
count = fetch_star_count(os.environ.get("GITHUB_TOKEN", ""))
|
||||
except FetchError as exc:
|
||||
print(f"error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
except Exception:
|
||||
print("error: unexpected internal failure", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
print(count)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 20 KiB |
|
|
@ -0,0 +1,240 @@
|
|||
import ast
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import threading
|
||||
import unittest
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from scripts import fetch_star_count
|
||||
|
||||
|
||||
TOKEN_SENTINEL = "TOKEN_FETCH_ONLY_DO_NOT_LEAK_7z9"
|
||||
|
||||
|
||||
class RecordingHandler(BaseHTTPRequestHandler):
|
||||
def do_GET(self):
|
||||
self.server.requests.append(
|
||||
{"path": self.path, "headers": dict(self.headers.items())}
|
||||
)
|
||||
response = self.server.response
|
||||
self.send_response(response["status"])
|
||||
for name, value in response.get("headers", {}).items():
|
||||
self.send_header(name, value)
|
||||
self.end_headers()
|
||||
try:
|
||||
self.wfile.write(response.get("body", b""))
|
||||
except BrokenPipeError:
|
||||
pass
|
||||
|
||||
def log_message(self, *_args):
|
||||
return
|
||||
|
||||
|
||||
class LocalHttpServer:
|
||||
def __init__(self, *, status=200, headers=None, body=b""):
|
||||
self.response = {
|
||||
"status": status,
|
||||
"headers": headers or {},
|
||||
"body": body,
|
||||
}
|
||||
|
||||
def __enter__(self):
|
||||
self.server = HTTPServer(("127.0.0.1", 0), RecordingHandler)
|
||||
self.server.requests = []
|
||||
self.server.response = self.response
|
||||
self.thread = threading.Thread(
|
||||
target=self.server.serve_forever,
|
||||
kwargs={"poll_interval": 0.01},
|
||||
daemon=True,
|
||||
)
|
||||
self.thread.start()
|
||||
host, port = self.server.server_address
|
||||
self.url = f"http://{host}:{port}/repository"
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc, _traceback):
|
||||
self.server.shutdown()
|
||||
self.server.server_close()
|
||||
self.thread.join(timeout=2)
|
||||
|
||||
|
||||
class FetchStarCountTests(unittest.TestCase):
|
||||
def run_main(self, api_url):
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
with (
|
||||
patch.object(fetch_star_count, "API_URL", api_url),
|
||||
patch.dict(os.environ, {"GITHUB_TOKEN": TOKEN_SENTINEL}, clear=False),
|
||||
redirect_stdout(stdout),
|
||||
redirect_stderr(stderr),
|
||||
):
|
||||
exit_code = fetch_star_count.main([])
|
||||
return exit_code, stdout.getvalue(), stderr.getvalue()
|
||||
|
||||
def test_fetcher_is_standalone_stdlib_only_and_has_fixed_api(self):
|
||||
source_path = Path(fetch_star_count.__file__)
|
||||
tree = ast.parse(source_path.read_text(encoding="utf-8"))
|
||||
imported_roots = set()
|
||||
for node in ast.walk(tree):
|
||||
if isinstance(node, ast.Import):
|
||||
imported_roots.update(alias.name.split(".", 1)[0] for alias in node.names)
|
||||
elif isinstance(node, ast.ImportFrom) and node.module:
|
||||
imported_roots.add(node.module.split(".", 1)[0])
|
||||
|
||||
self.assertNotIn("scripts", imported_roots)
|
||||
self.assertNotIn("star_history", imported_roots)
|
||||
self.assertEqual(
|
||||
fetch_star_count.API_URL,
|
||||
"https://api.github.com/repos/666ghj/MiroFish",
|
||||
)
|
||||
|
||||
def test_success_stdout_is_only_one_decimal_count(self):
|
||||
body = json.dumps({"stargazers_count": 41782}).encode()
|
||||
with LocalHttpServer(body=body) as server:
|
||||
exit_code, stdout, stderr = self.run_main(server.url)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(stdout, "41782\n")
|
||||
self.assertEqual(stderr, "")
|
||||
self.assertEqual(len(server.server.requests), 1)
|
||||
request = server.server.requests[0]
|
||||
self.assertEqual(request["path"], "/repository")
|
||||
self.assertEqual(
|
||||
request["headers"]["Authorization"], f"Bearer {TOKEN_SENTINEL}"
|
||||
)
|
||||
|
||||
def test_redirect_is_refused_without_forwarding_token(self):
|
||||
with LocalHttpServer(body=b'{"stargazers_count": 99}') as target:
|
||||
with LocalHttpServer(
|
||||
status=302,
|
||||
headers={"Location": target.url},
|
||||
body=f"unsafe-body {TOKEN_SENTINEL}".encode(),
|
||||
) as source:
|
||||
exit_code, stdout, stderr = self.run_main(source.url)
|
||||
|
||||
self.assertEqual(exit_code, 1)
|
||||
self.assertEqual(stdout, "")
|
||||
self.assertEqual(target.server.requests, [])
|
||||
self.assertIn("redirect was refused", stderr)
|
||||
self.assertNotIn(TOKEN_SENTINEL, stderr)
|
||||
self.assertNotIn("unsafe-body", stderr)
|
||||
|
||||
def test_malformed_oversized_and_invalid_counts_are_sanitized(self):
|
||||
bodies = [
|
||||
b"not-json " + TOKEN_SENTINEL.encode(),
|
||||
b"x" * (fetch_star_count.MAX_HTTP_BYTES + 1),
|
||||
json.dumps([]).encode(),
|
||||
json.dumps({}).encode(),
|
||||
json.dumps({"stargazers_count": True}).encode(),
|
||||
json.dumps({"stargazers_count": -1}).encode(),
|
||||
json.dumps({"stargazers_count": 1.5}).encode(),
|
||||
json.dumps({"stargazers_count": "1"}).encode(),
|
||||
]
|
||||
for body in bodies:
|
||||
with self.subTest(body_prefix=body[:32]):
|
||||
with LocalHttpServer(body=body) as server:
|
||||
exit_code, stdout, stderr = self.run_main(server.url)
|
||||
|
||||
self.assertEqual(exit_code, 1)
|
||||
self.assertEqual(stdout, "")
|
||||
self.assertTrue(stderr.startswith("error: "))
|
||||
self.assertNotIn(TOKEN_SENTINEL, stderr)
|
||||
self.assertNotIn("not-json", stderr)
|
||||
|
||||
def test_status_and_network_errors_do_not_echo_exception_data(self):
|
||||
with LocalHttpServer(
|
||||
status=500,
|
||||
body=f"unsafe-body {TOKEN_SENTINEL}".encode(),
|
||||
) as server:
|
||||
exit_code, stdout, stderr = self.run_main(server.url)
|
||||
|
||||
self.assertEqual(exit_code, 1)
|
||||
self.assertEqual(stdout, "")
|
||||
self.assertIn("unavailable", stderr)
|
||||
self.assertNotIn(TOKEN_SENTINEL, stderr)
|
||||
self.assertNotIn("unsafe-body", stderr)
|
||||
|
||||
class FailingOpener:
|
||||
def open(self, *_args, **_kwargs):
|
||||
raise OSError(TOKEN_SENTINEL)
|
||||
|
||||
with self.assertRaises(fetch_star_count.FetchError) as captured:
|
||||
fetch_star_count.fetch_star_count(TOKEN_SENTINEL, FailingOpener())
|
||||
self.assertNotIn(TOKEN_SENTINEL, str(captured.exception))
|
||||
|
||||
def test_missing_or_newline_token_is_rejected_without_stdout(self):
|
||||
for token in ("", "bad\ntoken", "bad\rtoken"):
|
||||
with self.subTest(token=repr(token)):
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
with (
|
||||
patch.dict(os.environ, {"GITHUB_TOKEN": token}, clear=False),
|
||||
redirect_stdout(stdout),
|
||||
redirect_stderr(stderr),
|
||||
):
|
||||
exit_code = fetch_star_count.main([])
|
||||
self.assertEqual(exit_code, 1)
|
||||
self.assertEqual(stdout.getvalue(), "")
|
||||
if token:
|
||||
self.assertNotIn(token, stderr.getvalue())
|
||||
|
||||
def test_workflow_keeps_credentials_out_of_record_and_render_steps(self):
|
||||
repository = Path(__file__).resolve().parents[1]
|
||||
workflow = (
|
||||
repository / ".github/workflows/update-star-history.yml"
|
||||
).read_text(encoding="utf-8")
|
||||
renderer = (repository / "scripts/star_history.py").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
|
||||
self.assertIn("cron: '17 3 1,16 * *'", workflow)
|
||||
self.assertIn("timezone: 'UTC'", workflow)
|
||||
self.assertNotIn("due-check:", workflow)
|
||||
self.assertNotIn("star_history.py due", workflow)
|
||||
self.assertNotIn("inputs.force", workflow)
|
||||
self.assertNotIn("actions/checkout", workflow)
|
||||
self.assertNotIn("uses:", workflow)
|
||||
self.assertNotIn("pull_request:", workflow)
|
||||
self.assertNotIn("pull_request_target:", workflow)
|
||||
self.assertNotIn("workflow_run:", workflow)
|
||||
self.assertNotIn("secrets.", workflow)
|
||||
self.assertIn("sha256sum --check --strict", workflow)
|
||||
self.assertIn("-c core.hooksPath=/dev/null", workflow)
|
||||
self.assertNotIn("star_history.py sample", workflow)
|
||||
self.assertNotIn('os.environ.get("GITHUB_TOKEN"', renderer)
|
||||
self.assertNotIn('subparsers.add_parser("sample"', renderer)
|
||||
|
||||
token_steps = [
|
||||
section
|
||||
for section in workflow.split("\n - name: ")
|
||||
if "GITHUB_TOKEN: ${{ github.token }}" in section
|
||||
]
|
||||
self.assertGreaterEqual(len(token_steps), 2)
|
||||
for section in token_steps:
|
||||
step_name = section.splitlines()[0]
|
||||
self.assertTrue(
|
||||
step_name.startswith("Fetch aggregate Star count only")
|
||||
or step_name.startswith(
|
||||
"Push one allowlisted commit with an ephemeral credential"
|
||||
)
|
||||
)
|
||||
|
||||
record_steps = [
|
||||
section
|
||||
for section in workflow.split("\n - name: ")
|
||||
if "star_history.py record" in section
|
||||
]
|
||||
self.assertEqual(len(record_steps), 1)
|
||||
for section in record_steps:
|
||||
self.assertIn("GITHUB_TOKEN: ''", section)
|
||||
self.assertIn("GH_TOKEN: ''", section)
|
||||
self.assertNotIn("${{ github.token }}", section)
|
||||
self.assertIn("--force", section)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
|
|
@ -0,0 +1,954 @@
|
|||
import base64
|
||||
import hashlib
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
import xml.etree.ElementTree as ET
|
||||
from contextlib import redirect_stderr, redirect_stdout
|
||||
from copy import deepcopy
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from scripts import star_history
|
||||
|
||||
|
||||
UTC = timezone.utc
|
||||
TOKEN_SENTINEL = "TOKEN_TEST_DO_NOT_LEAK_7z9"
|
||||
|
||||
|
||||
class FixedClock:
|
||||
def __init__(self, value: str):
|
||||
self.value = datetime.strptime(value, "%Y-%m-%dT%H:%M:%SZ").replace(
|
||||
tzinfo=UTC
|
||||
)
|
||||
|
||||
def now(self):
|
||||
return self.value
|
||||
|
||||
|
||||
class FakeGitHub:
|
||||
def __init__(self, pages=None):
|
||||
self.pages = pages or {}
|
||||
self.page_calls = []
|
||||
|
||||
def fetch_stargazer_page(self, after):
|
||||
self.page_calls.append(after)
|
||||
return self.pages[after]
|
||||
|
||||
|
||||
class FakeRunner:
|
||||
def __init__(self, completed):
|
||||
self.completed = completed
|
||||
self.arguments = None
|
||||
|
||||
def run(self, arguments):
|
||||
self.arguments = list(arguments)
|
||||
return self.completed
|
||||
|
||||
|
||||
def edge(cursor, timestamp):
|
||||
return star_history.StargazerEdge(
|
||||
cursor,
|
||||
datetime.fromisoformat(timestamp.replace("Z", "+00:00")),
|
||||
)
|
||||
|
||||
|
||||
def page(total, edges, has_next=False, end_cursor=None, remaining=1_000):
|
||||
return star_history.StargazerPage(
|
||||
total_count=total,
|
||||
edges=tuple(edges),
|
||||
has_next_page=has_next,
|
||||
end_cursor=end_cursor,
|
||||
rate_remaining=remaining,
|
||||
)
|
||||
|
||||
|
||||
def state_with_snapshots(snapshots=None):
|
||||
return {
|
||||
"schema_version": 1,
|
||||
"repository": "666ghj/MiroFish",
|
||||
"timezone": "UTC",
|
||||
"ongoing_interval_days": 13,
|
||||
"reconstruction": {
|
||||
"method": "current_stargazers_starred_at",
|
||||
"generated_at": "2026-07-01T00:00:00Z",
|
||||
"daily": [],
|
||||
},
|
||||
"snapshots": snapshots or [],
|
||||
}
|
||||
|
||||
|
||||
def seed_workspace(workspace, state):
|
||||
state_path = workspace / ".github/star-history/history.json"
|
||||
light_path = workspace / "static/image/star-history-light.svg"
|
||||
dark_path = workspace / "static/image/star-history-dark.svg"
|
||||
state_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
light_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
state_path.write_bytes(star_history.canonical_state_bytes(state))
|
||||
light_path.write_bytes(star_history.render_svg(state, "light"))
|
||||
dark_path.write_bytes(star_history.render_svg(state, "dark"))
|
||||
return state_path, light_path, dark_path
|
||||
|
||||
|
||||
class StarHistoryBehaviorTests(unittest.TestCase):
|
||||
def test_backfill_writes_hand_computed_completed_daily_history(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
edges = [
|
||||
edge("c6", "2026-03-02T00:01:00Z"),
|
||||
edge("c5", "2026-03-01T00:00:00Z"),
|
||||
edge("c4", "2026-02-28T23:59:59Z"),
|
||||
edge("c3", "2026-02-28T23:59:59Z"),
|
||||
edge("c2", "2026-02-27T10:00:00Z"),
|
||||
edge("c1", "2026-02-25T12:00:00Z"),
|
||||
]
|
||||
github = FakeGitHub({None: page(6, edges)})
|
||||
|
||||
result = star_history.execute(
|
||||
"backfill",
|
||||
github=github,
|
||||
clock=FixedClock("2026-03-02T12:00:00Z"),
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
self.assertTrue(result.changed)
|
||||
state = json.loads(
|
||||
(workspace / ".github/star-history/history.json").read_text()
|
||||
)
|
||||
self.assertEqual(
|
||||
state["reconstruction"]["daily"],
|
||||
[
|
||||
{"date": "2026-02-25", "stars": 1},
|
||||
{"date": "2026-02-27", "stars": 2},
|
||||
{"date": "2026-02-28", "stars": 4},
|
||||
{"date": "2026-03-01", "stars": 5},
|
||||
],
|
||||
)
|
||||
self.assertEqual(state["snapshots"], [])
|
||||
star_history.check_workspace(workspace)
|
||||
|
||||
def test_backfill_reads_101_edges_across_pages(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
january_first = [
|
||||
edge(f"jan1-{index}", f"2026-01-01T00:00:{index % 60:02d}Z")
|
||||
for index in range(100)
|
||||
]
|
||||
first_edges = [edge("jan2", "2026-01-02T00:00:00Z")] + january_first[:99]
|
||||
github = FakeGitHub(
|
||||
{
|
||||
None: page(101, first_edges, True, "next-page"),
|
||||
"next-page": page(101, january_first[99:]),
|
||||
}
|
||||
)
|
||||
|
||||
star_history.execute(
|
||||
"backfill",
|
||||
github=github,
|
||||
clock=FixedClock("2026-01-03T00:00:00Z"),
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
state = json.loads(
|
||||
(workspace / ".github/star-history/history.json").read_text()
|
||||
)
|
||||
self.assertEqual(github.page_calls, [None, "next-page"])
|
||||
self.assertEqual(
|
||||
state["reconstruction"]["daily"],
|
||||
[
|
||||
{"date": "2026-01-01", "stars": 100},
|
||||
{"date": "2026-01-02", "stars": 101},
|
||||
],
|
||||
)
|
||||
|
||||
def test_backfill_fails_closed_on_cursor_or_count_inconsistency(self):
|
||||
cases = {
|
||||
"duplicate edge": page(
|
||||
2,
|
||||
[edge("same", "2026-01-01T00:00:00Z"), edge("same", "2026-01-02T00:00:00Z")],
|
||||
),
|
||||
"count mismatch": page(2, [edge("only", "2026-01-01T00:00:00Z")]),
|
||||
}
|
||||
for label, first_page in cases.items():
|
||||
with self.subTest(label=label), tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
with self.assertRaises(star_history.StarHistoryError):
|
||||
star_history.execute(
|
||||
"backfill",
|
||||
github=FakeGitHub({None: first_page}),
|
||||
clock=FixedClock("2026-01-03T00:00:00Z"),
|
||||
workspace=workspace,
|
||||
)
|
||||
self.assertFalse(
|
||||
(workspace / ".github/star-history/history.json").exists()
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
github = FakeGitHub(
|
||||
{
|
||||
None: page(
|
||||
2,
|
||||
[edge("first", "2026-01-02T00:00:00Z")],
|
||||
True,
|
||||
"repeated-page",
|
||||
),
|
||||
"repeated-page": page(
|
||||
2,
|
||||
[edge("second", "2026-01-01T00:00:00Z")],
|
||||
False,
|
||||
"repeated-page",
|
||||
),
|
||||
}
|
||||
)
|
||||
with self.assertRaises(star_history.StarHistoryError):
|
||||
star_history.execute(
|
||||
"backfill",
|
||||
github=github,
|
||||
clock=FixedClock("2026-01-03T00:00:00Z"),
|
||||
workspace=workspace,
|
||||
)
|
||||
self.assertFalse(
|
||||
(workspace / ".github/star-history/history.json").exists()
|
||||
)
|
||||
|
||||
def test_backfill_accepts_exact_rate_limit_reserve_after_first_page(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
github = FakeGitHub(
|
||||
{
|
||||
None: page(
|
||||
1,
|
||||
[edge("only", "2026-01-01T00:00:00Z")],
|
||||
remaining=star_history.RATE_LIMIT_RESERVE,
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
result = star_history.execute(
|
||||
"backfill",
|
||||
github=github,
|
||||
clock=FixedClock("2026-01-02T00:00:00Z"),
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
self.assertTrue(result.changed)
|
||||
self.assertEqual(github.page_calls, [None])
|
||||
|
||||
def test_backfill_refuses_dangling_output_symlink(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
state_path = workspace / ".github/star-history/history.json"
|
||||
state_path.parent.mkdir(parents=True)
|
||||
state_path.symlink_to(workspace / "missing-history.json")
|
||||
|
||||
with self.assertRaises(star_history.StarHistoryError):
|
||||
star_history.execute(
|
||||
"backfill",
|
||||
github=FakeGitHub({None: page(0, [])}),
|
||||
clock=FixedClock("2026-01-02T00:00:00Z"),
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
self.assertTrue(state_path.is_symlink())
|
||||
|
||||
def test_initialize_starts_from_one_honest_aggregate_snapshot(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
result = star_history.execute(
|
||||
"initialize",
|
||||
github=None,
|
||||
clock=FixedClock("2026-07-20T05:00:00Z"),
|
||||
workspace=workspace,
|
||||
star_count=42,
|
||||
)
|
||||
|
||||
self.assertTrue(result.changed)
|
||||
state = star_history.load_state(workspace)
|
||||
self.assertEqual(
|
||||
state["reconstruction"]["method"], "aggregate_snapshot_only"
|
||||
)
|
||||
self.assertEqual(state["reconstruction"]["daily"], [])
|
||||
self.assertEqual(
|
||||
state["snapshots"],
|
||||
[{"at": "2026-07-20T05:00:00Z", "stars": 42}],
|
||||
)
|
||||
star_history.check_workspace(workspace)
|
||||
|
||||
def test_due_boundary_matches_configured_interval(self):
|
||||
baseline = state_with_snapshots(
|
||||
[{"at": "2026-07-20T05:00:00Z", "stars": 100}]
|
||||
)
|
||||
latest = datetime(2026, 7, 20, 5, 0, 0, tzinfo=UTC)
|
||||
boundary = latest + star_history.timedelta(
|
||||
days=star_history.INTERVAL_DAYS
|
||||
)
|
||||
cases = [
|
||||
(
|
||||
(boundary - star_history.timedelta(seconds=1)).strftime(
|
||||
"%Y-%m-%dT%H:%M:%SZ"
|
||||
),
|
||||
False,
|
||||
),
|
||||
(boundary.strftime("%Y-%m-%dT%H:%M:%SZ"), True),
|
||||
]
|
||||
for now, expected in cases:
|
||||
with self.subTest(now=now), tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
seed_workspace(workspace, baseline)
|
||||
result = star_history.execute(
|
||||
"due",
|
||||
github=None,
|
||||
clock=FixedClock(now),
|
||||
workspace=workspace,
|
||||
)
|
||||
self.assertIs(result.due, expected)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
seed_workspace(workspace, state_with_snapshots())
|
||||
result = star_history.execute(
|
||||
"due",
|
||||
github=None,
|
||||
clock=FixedClock("2026-07-20T05:00:00Z"),
|
||||
workspace=workspace,
|
||||
)
|
||||
self.assertTrue(result.due)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
future_state = state_with_snapshots()
|
||||
future_state["reconstruction"]["generated_at"] = "2026-08-01T00:00:00Z"
|
||||
seed_workspace(workspace, future_state)
|
||||
with self.assertRaises(star_history.StarHistoryError):
|
||||
star_history.execute(
|
||||
"due",
|
||||
github=None,
|
||||
clock=FixedClock("2026-07-20T05:00:00Z"),
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
seed_workspace(workspace, baseline)
|
||||
with self.assertRaises(star_history.StarHistoryError):
|
||||
star_history.execute(
|
||||
"due",
|
||||
github=None,
|
||||
clock=FixedClock("2026-07-20T04:59:59Z"),
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
def test_record_before_due_does_not_write(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
state_path, light_path, dark_path = seed_workspace(
|
||||
workspace,
|
||||
state_with_snapshots(
|
||||
[{"at": "2026-07-20T05:00:00Z", "stars": 100}]
|
||||
),
|
||||
)
|
||||
before = tuple(path.read_bytes() for path in (state_path, light_path, dark_path))
|
||||
latest = datetime(2026, 7, 20, 5, 0, 0, tzinfo=UTC)
|
||||
before_due = latest + star_history.timedelta(
|
||||
days=star_history.INTERVAL_DAYS
|
||||
) - star_history.timedelta(seconds=1)
|
||||
result = star_history.execute(
|
||||
"record",
|
||||
github=None,
|
||||
clock=FixedClock(before_due.strftime("%Y-%m-%dT%H:%M:%SZ")),
|
||||
workspace=workspace,
|
||||
star_count=101,
|
||||
)
|
||||
|
||||
self.assertFalse(result.changed)
|
||||
self.assertEqual(
|
||||
before,
|
||||
tuple(path.read_bytes() for path in (state_path, light_path, dark_path)),
|
||||
)
|
||||
|
||||
def test_record_applies_count_file_without_github_credentials(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
state_path, light_path, dark_path = seed_workspace(
|
||||
workspace, state_with_snapshots()
|
||||
)
|
||||
count_file = workspace / "fetched-count"
|
||||
count_file.write_bytes(b"123\n")
|
||||
|
||||
stdout = io.StringIO()
|
||||
stderr = io.StringIO()
|
||||
with (
|
||||
patch.dict(
|
||||
os.environ,
|
||||
{"GITHUB_TOKEN": TOKEN_SENTINEL, "GH_TOKEN": TOKEN_SENTINEL},
|
||||
clear=False,
|
||||
),
|
||||
patch.object(star_history, "_repository_root", return_value=workspace),
|
||||
patch.object(
|
||||
star_history,
|
||||
"SystemClock",
|
||||
return_value=FixedClock("2026-07-20T05:00:00Z"),
|
||||
),
|
||||
redirect_stdout(stdout),
|
||||
redirect_stderr(stderr),
|
||||
):
|
||||
exit_code = star_history.main(
|
||||
["record", "--count-file", str(count_file), "--force"]
|
||||
)
|
||||
|
||||
self.assertEqual(exit_code, 0)
|
||||
self.assertEqual(stderr.getvalue(), "")
|
||||
self.assertNotIn(TOKEN_SENTINEL, stdout.getvalue())
|
||||
self.assertEqual(
|
||||
star_history.load_state(workspace)["snapshots"],
|
||||
[{"at": "2026-07-20T05:00:00Z", "stars": 123}],
|
||||
)
|
||||
for output in (state_path, light_path, dark_path):
|
||||
self.assertNotIn(TOKEN_SENTINEL.encode(), output.read_bytes())
|
||||
star_history.check_workspace(workspace)
|
||||
|
||||
def test_count_file_rejects_symlinks_malformed_and_extreme_values(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
root = Path(temporary)
|
||||
valid = root / "valid"
|
||||
valid.write_bytes(b"0\n")
|
||||
self.assertEqual(star_history.load_star_count_file(valid), 0)
|
||||
|
||||
cases = {
|
||||
"empty": b"",
|
||||
"negative": b"-1\n",
|
||||
"leading-zero": b"01\n",
|
||||
"json": b'{"stargazers_count": 1}\n',
|
||||
"too-large": b"9" * (star_history.MAX_COUNT_FILE_BYTES + 1),
|
||||
"out-of-range": str(star_history.MAX_STAR_COUNT + 1).encode() + b"\n",
|
||||
}
|
||||
for name, payload in cases.items():
|
||||
path = root / name
|
||||
path.write_bytes(payload)
|
||||
with self.subTest(name=name), self.assertRaises(
|
||||
star_history.StarHistoryError
|
||||
):
|
||||
star_history.load_star_count_file(path)
|
||||
|
||||
link = root / "link"
|
||||
link.symlink_to(valid)
|
||||
with self.assertRaises(star_history.StarHistoryError):
|
||||
star_history.load_star_count_file(link)
|
||||
|
||||
def test_force_same_day_same_count_is_idempotent(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
state_path, light_path, dark_path = seed_workspace(
|
||||
workspace,
|
||||
state_with_snapshots(
|
||||
[{"at": "2026-07-20T05:00:00Z", "stars": 100}]
|
||||
),
|
||||
)
|
||||
before = tuple(path.read_bytes() for path in (state_path, light_path, dark_path))
|
||||
|
||||
result = star_history.execute(
|
||||
"record",
|
||||
github=None,
|
||||
clock=FixedClock("2026-07-20T06:00:00Z"),
|
||||
workspace=workspace,
|
||||
force=True,
|
||||
star_count=100,
|
||||
)
|
||||
|
||||
self.assertFalse(result.changed)
|
||||
self.assertEqual(
|
||||
before,
|
||||
tuple(path.read_bytes() for path in (state_path, light_path, dark_path)),
|
||||
)
|
||||
|
||||
def test_force_same_day_changed_count_replaces_snapshot(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
seed_workspace(
|
||||
workspace,
|
||||
state_with_snapshots(
|
||||
[{"at": "2026-07-20T05:00:00Z", "stars": 100}]
|
||||
),
|
||||
)
|
||||
|
||||
result = star_history.execute(
|
||||
"record",
|
||||
github=None,
|
||||
clock=FixedClock("2026-07-20T06:00:00Z"),
|
||||
workspace=workspace,
|
||||
force=True,
|
||||
star_count=101,
|
||||
)
|
||||
|
||||
self.assertTrue(result.changed)
|
||||
state = star_history.load_state(workspace)
|
||||
self.assertEqual(
|
||||
state["snapshots"],
|
||||
[{"at": "2026-07-20T06:00:00Z", "stars": 101}],
|
||||
)
|
||||
|
||||
def test_new_date_appends_even_when_count_is_unchanged(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
seed_workspace(
|
||||
workspace,
|
||||
state_with_snapshots(
|
||||
[{"at": "2026-07-20T05:00:00Z", "stars": 100}]
|
||||
),
|
||||
)
|
||||
|
||||
result = star_history.execute(
|
||||
"record",
|
||||
github=None,
|
||||
clock=FixedClock("2026-08-04T05:00:00Z"),
|
||||
workspace=workspace,
|
||||
star_count=100,
|
||||
)
|
||||
|
||||
self.assertTrue(result.changed)
|
||||
self.assertEqual(
|
||||
star_history.load_state(workspace)["snapshots"],
|
||||
[
|
||||
{"at": "2026-07-20T05:00:00Z", "stars": 100},
|
||||
{"at": "2026-08-04T05:00:00Z", "stars": 100},
|
||||
],
|
||||
)
|
||||
|
||||
def test_schema_rejects_unknown_identity_fields_and_boolean_counts(self):
|
||||
baseline = state_with_snapshots()
|
||||
cases = []
|
||||
top = deepcopy(baseline)
|
||||
top["login"] = "secret-user"
|
||||
cases.append(top)
|
||||
reconstruction = deepcopy(baseline)
|
||||
reconstruction["reconstruction"]["avatar"] = "secret-avatar"
|
||||
cases.append(reconstruction)
|
||||
daily = deepcopy(baseline)
|
||||
daily["reconstruction"]["daily"] = [
|
||||
{"date": "2026-06-30", "stars": 1, "user": "secret-user"}
|
||||
]
|
||||
cases.append(daily)
|
||||
snapshot = deepcopy(baseline)
|
||||
snapshot["snapshots"] = [
|
||||
{"at": "2026-07-20T05:00:00Z", "stars": 1, "profile_url": "secret"}
|
||||
]
|
||||
cases.append(snapshot)
|
||||
boolean_count = deepcopy(baseline)
|
||||
boolean_count["snapshots"] = [
|
||||
{"at": "2026-07-20T05:00:00Z", "stars": True}
|
||||
]
|
||||
cases.append(boolean_count)
|
||||
|
||||
for state in cases:
|
||||
with self.subTest(state=state), self.assertRaises(
|
||||
star_history.StarHistoryError
|
||||
):
|
||||
star_history.validate_state(state)
|
||||
|
||||
def test_svg_is_accessible_self_contained_and_deterministic(self):
|
||||
state = {
|
||||
"schema_version": 1,
|
||||
"repository": "666ghj/MiroFish",
|
||||
"timezone": "UTC",
|
||||
"ongoing_interval_days": 13,
|
||||
"reconstruction": {
|
||||
"method": "current_stargazers_starred_at",
|
||||
"generated_at": "2026-01-04T00:00:00Z",
|
||||
"daily": [
|
||||
{"date": "2026-01-01", "stars": 1},
|
||||
{"date": "2026-01-03", "stars": 3},
|
||||
],
|
||||
},
|
||||
"snapshots": [
|
||||
{"at": "2026-01-20T10:00:00Z", "stars": 3},
|
||||
{"at": "2026-02-04T10:00:00Z", "stars": 2},
|
||||
],
|
||||
}
|
||||
|
||||
light = star_history.render_svg(state, "light")
|
||||
dark = star_history.render_svg(state, "dark")
|
||||
|
||||
self.assertEqual(light, star_history.render_svg(state, "light"))
|
||||
self.assertNotEqual(light, dark)
|
||||
self.assertIn(b"viewBox=\"0 0 800 533.333\"", light)
|
||||
self.assertIn(b"Star History", light)
|
||||
self.assertIn(b"666ghj/MiroFish", light)
|
||||
self.assertIn(b"star-history.com", light)
|
||||
self.assertIn(b"feTurbulence", light)
|
||||
self.assertIn(b"feDisplacementMap", light)
|
||||
self.assertIn(b"filter=\"url(#xkcdify)\"", light)
|
||||
self.assertNotIn(b"stroke-dasharray", light)
|
||||
self.assertNotIn(b"<polyline", light)
|
||||
self.assertNotIn(TOKEN_SENTINEL.encode(), light)
|
||||
root = ET.fromstring(light)
|
||||
names = {element.tag.rsplit("}", 1)[-1] for element in root.iter()}
|
||||
self.assertIn("title", names)
|
||||
self.assertIn("desc", names)
|
||||
self.assertIn("filter", names)
|
||||
self.assertIn("image", names)
|
||||
self.assertTrue(names.isdisjoint({"script", "foreignObject"}))
|
||||
images = [
|
||||
element
|
||||
for element in root.iter()
|
||||
if element.tag.rsplit("}", 1)[-1] == "image"
|
||||
]
|
||||
self.assertEqual(len(images), 2)
|
||||
images_by_href = {element.attrib["href"]: element for element in images}
|
||||
avatar_image = images_by_href[star_history.OWNER_AVATAR_DATA_URI]
|
||||
watermark_image = images_by_href[star_history.WATERMARK_LOGO_DATA_URI]
|
||||
self.assertEqual(
|
||||
avatar_image.attrib["href"], star_history.OWNER_AVATAR_DATA_URI
|
||||
)
|
||||
self.assertEqual(avatar_image.attrib["width"], "22")
|
||||
self.assertEqual(avatar_image.attrib["height"], "22")
|
||||
avatar = base64.b64decode(
|
||||
star_history.OWNER_AVATAR_BASE64, validate=True
|
||||
)
|
||||
self.assertLessEqual(len(avatar), star_history.MAX_INLINE_AVATAR_BYTES)
|
||||
self.assertEqual(
|
||||
hashlib.sha256(avatar).hexdigest(),
|
||||
star_history.OWNER_AVATAR_SHA256,
|
||||
)
|
||||
self.assertEqual(
|
||||
star_history._reviewed_avatar_dimensions(avatar),
|
||||
star_history.OWNER_AVATAR_DIMENSIONS,
|
||||
)
|
||||
watermark = base64.b64decode(
|
||||
star_history.WATERMARK_LOGO_BASE64, validate=True
|
||||
)
|
||||
self.assertEqual(len(watermark), 4_185)
|
||||
self.assertEqual(
|
||||
hashlib.sha256(watermark).hexdigest(),
|
||||
star_history.WATERMARK_LOGO_SHA256,
|
||||
)
|
||||
self.assertEqual(
|
||||
(
|
||||
int.from_bytes(watermark[16:20], "big"),
|
||||
int.from_bytes(watermark[20:24], "big"),
|
||||
),
|
||||
star_history.WATERMARK_LOGO_DIMENSIONS,
|
||||
)
|
||||
self.assertEqual(watermark_image.attrib["width"], "20")
|
||||
self.assertEqual(watermark_image.attrib["height"], "20")
|
||||
curves = [
|
||||
element
|
||||
for element in root.iter()
|
||||
if element.attrib.get("class") == "xkcd-chart-xyline"
|
||||
]
|
||||
self.assertEqual(len(curves), 1)
|
||||
self.assertFalse(curves[0].attrib["d"].startswith("M70,483.33"))
|
||||
self.assertIn("C", curves[0].attrib["d"])
|
||||
visible_labels = [
|
||||
element.text or ""
|
||||
for element in root.iter()
|
||||
if element.tag.rsplit("}", 1)[-1] == "text"
|
||||
]
|
||||
self.assertFalse(
|
||||
any(
|
||||
word in label.lower()
|
||||
for label in visible_labels
|
||||
for word in ("reconstructed", "snapshot")
|
||||
)
|
||||
)
|
||||
legend_labels = [
|
||||
element.text
|
||||
for element in root.iter()
|
||||
if element.tag.rsplit("}", 1)[-1] == "text"
|
||||
and element.text == "666ghj/MiroFish"
|
||||
]
|
||||
self.assertEqual(legend_labels, ["666ghj/MiroFish"])
|
||||
for element in root.iter():
|
||||
for name, value in element.attrib.items():
|
||||
local = name.rsplit("}", 1)[-1].lower()
|
||||
self.assertFalse(local.startswith("on"))
|
||||
self.assertNotEqual(local, "src")
|
||||
if local == "href":
|
||||
self.assertIn(
|
||||
value,
|
||||
{
|
||||
star_history.OWNER_AVATAR_DATA_URI,
|
||||
star_history.WATERMARK_LOGO_DATA_URI,
|
||||
},
|
||||
)
|
||||
self.assertFalse(value.lower().startswith(("http:", "https:", "//")))
|
||||
|
||||
single_point = star_history.render_svg(
|
||||
state_with_snapshots(
|
||||
[{"at": "2026-07-20T05:00:00Z", "stars": 42}]
|
||||
),
|
||||
"light",
|
||||
)
|
||||
single_root = ET.fromstring(single_point)
|
||||
single_curve = next(
|
||||
element
|
||||
for element in single_root.iter()
|
||||
if element.attrib.get("class") == "xkcd-chart-xyline"
|
||||
)
|
||||
self.assertEqual(single_curve.attrib["d"], "M416,60H424")
|
||||
self.assertNotIn("483.33", single_curve.attrib["d"])
|
||||
self.assertNotIn("L", single_curve.attrib["d"])
|
||||
self.assertNotIn("C", single_curve.attrib["d"])
|
||||
single_text = [
|
||||
element.text
|
||||
for element in single_root.iter()
|
||||
if element.tag.rsplit("}", 1)[-1] == "text"
|
||||
]
|
||||
self.assertEqual(
|
||||
[
|
||||
label
|
||||
for label in single_text
|
||||
if label in {"Sun 19", "Mon 20", "Tue 21"}
|
||||
],
|
||||
["Sun 19", "Mon 20", "Tue 21"],
|
||||
)
|
||||
|
||||
medium_window = state_with_snapshots(
|
||||
[
|
||||
{"at": "2026-01-01T00:00:00Z", "stars": 1},
|
||||
{"at": "2026-04-01T00:00:00Z", "stars": 2},
|
||||
]
|
||||
)
|
||||
medium_window["reconstruction"]["generated_at"] = "2026-01-01T00:00:00Z"
|
||||
medium_text = [
|
||||
element.text or ""
|
||||
for element in ET.fromstring(
|
||||
star_history.render_svg(medium_window, "light")
|
||||
).iter()
|
||||
if element.tag.rsplit("}", 1)[-1] == "text"
|
||||
]
|
||||
date_ticks = [
|
||||
label
|
||||
for label in medium_text
|
||||
if len(label.split()) == 2
|
||||
and label.split()[0].isdigit()
|
||||
and len(label.split()[1]) == 3
|
||||
]
|
||||
self.assertEqual(len(date_ticks), 6)
|
||||
self.assertEqual(len(set(date_ticks)), 6)
|
||||
|
||||
short_window = {
|
||||
"schema_version": 1,
|
||||
"repository": "666ghj/MiroFish",
|
||||
"timezone": "UTC",
|
||||
"ongoing_interval_days": 13,
|
||||
"reconstruction": {
|
||||
"method": "current_stargazers_starred_at",
|
||||
"generated_at": "2026-01-02T00:00:00Z",
|
||||
"daily": [{"date": "2026-01-01", "stars": 1}],
|
||||
},
|
||||
"snapshots": [{"at": "2026-01-02T05:00:00Z", "stars": 2}],
|
||||
}
|
||||
short_text = [
|
||||
element.text or ""
|
||||
for element in ET.fromstring(
|
||||
star_history.render_svg(short_window, "light")
|
||||
).iter()
|
||||
if element.tag.rsplit("}", 1)[-1] == "text"
|
||||
]
|
||||
weekday_ticks = [
|
||||
label
|
||||
for label in short_text
|
||||
if label.split()[0]
|
||||
in {"Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"}
|
||||
]
|
||||
self.assertEqual(weekday_ticks, ["Fri 02"])
|
||||
|
||||
earliest_state = state_with_snapshots()
|
||||
earliest_state["reconstruction"]["generated_at"] = "0001-01-01T00:00:00Z"
|
||||
self.assertIn(
|
||||
b"Star History", star_history.render_svg(earliest_state, "light")
|
||||
)
|
||||
|
||||
empty_root = ET.fromstring(
|
||||
star_history.render_svg(state_with_snapshots(), "light")
|
||||
)
|
||||
empty_y_ticks = [
|
||||
float(element.attrib["y1"])
|
||||
for element in empty_root.iter()
|
||||
if element.tag.rsplit("}", 1)[-1] == "line"
|
||||
and element.attrib.get("x1") == "69"
|
||||
and element.attrib.get("x2") == "70"
|
||||
]
|
||||
self.assertEqual(len(empty_y_ticks), 5)
|
||||
self.assertTrue(all(60 <= value <= 483.333 for value in empty_y_ticks))
|
||||
|
||||
def test_svg_validator_rejects_unreviewed_or_active_content(self):
|
||||
reviewed_avatar = (
|
||||
'<image x="316" y="12" width="22" height="22" href="'
|
||||
+ star_history.OWNER_AVATAR_DATA_URI
|
||||
+ '" clip-path="url(#clip-circle-title)"/>'
|
||||
)
|
||||
reviewed_watermark = (
|
||||
'<image x="635" y="508.333" width="20" height="20" href="'
|
||||
+ star_history.WATERMARK_LOGO_DATA_URI
|
||||
+ '"/>'
|
||||
)
|
||||
valid_shell = (
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">'
|
||||
+ reviewed_avatar
|
||||
+ reviewed_watermark
|
||||
+ "</svg>"
|
||||
)
|
||||
processing_instruction = (
|
||||
'<?xml-stylesheet href="https://example.invalid/x.css"?>'
|
||||
+ valid_shell
|
||||
)
|
||||
unsafe_payloads = [
|
||||
b'<svg><image href="https://example.invalid/avatar.jpg"/></svg>',
|
||||
b'<svg><image href="data:image/jpeg;base64,/9j/2Q=="/></svg>',
|
||||
b'<svg onload="alert(1)"></svg>',
|
||||
b'<svg><script>unsafe</script></svg>',
|
||||
b'<svg><path filter="url(https://example.invalid/filter.svg)"/></svg>',
|
||||
(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">'
|
||||
+ reviewed_avatar
|
||||
+ '<style>@import url(https://example.invalid/x.css)</style></svg>'
|
||||
).encode(),
|
||||
(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" '
|
||||
'style="background:url(https://example.invalid/x.png)">'
|
||||
+ reviewed_avatar
|
||||
+ "</svg>"
|
||||
).encode(),
|
||||
processing_instruction.encode(),
|
||||
processing_instruction.encode("utf-16"),
|
||||
processing_instruction.encode("utf-16-be"),
|
||||
b"\xef\xbb\xbf" + valid_shell.encode(),
|
||||
(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">'
|
||||
'<image href="'
|
||||
+ star_history.OWNER_AVATAR_DATA_URI
|
||||
+ '"/></svg>'
|
||||
).encode(),
|
||||
valid_shell.replace(
|
||||
"</svg>",
|
||||
'<path filter="u\\72l(https://example.invalid/f.svg)"/></svg>',
|
||||
).encode(),
|
||||
valid_shell.replace(
|
||||
"</svg>",
|
||||
'<path filter="u/**/rl(https://example.invalid/f.svg)"/></svg>',
|
||||
).encode(),
|
||||
valid_shell.replace(
|
||||
"</svg>",
|
||||
'<path fill="u\\000072l(https://example.invalid/f.svg)"/></svg>',
|
||||
).encode(),
|
||||
valid_shell.replace(
|
||||
"</svg>",
|
||||
'<path filter="URL(HTTPS://example.invalid/f.svg)"/></svg>',
|
||||
).encode(),
|
||||
]
|
||||
for payload in unsafe_payloads:
|
||||
with self.subTest(payload=payload), self.assertRaises(
|
||||
star_history.StarHistoryError
|
||||
):
|
||||
star_history._validate_svg(payload)
|
||||
|
||||
def test_check_detects_svg_tampering(self):
|
||||
with tempfile.TemporaryDirectory() as temporary:
|
||||
workspace = Path(temporary)
|
||||
_, light_path, _ = seed_workspace(workspace, state_with_snapshots())
|
||||
light_path.write_bytes(
|
||||
light_path.read_bytes().replace(b"Star History", b"Star Historx", 1)
|
||||
)
|
||||
with self.assertRaises(star_history.StarHistoryError):
|
||||
star_history.execute(
|
||||
"check",
|
||||
github=None,
|
||||
clock=FixedClock("2026-07-20T05:00:00Z"),
|
||||
workspace=workspace,
|
||||
)
|
||||
|
||||
|
||||
class GitHubAdapterTests(unittest.TestCase):
|
||||
def test_graphql_gateway_uses_identity_free_paginated_query(self):
|
||||
payload = {
|
||||
"data": {
|
||||
"repository": {
|
||||
"stargazers": {
|
||||
"totalCount": 1,
|
||||
"edges": [
|
||||
{"cursor": "edge-cursor", "starredAt": "2026-01-01T00:00:00Z"}
|
||||
],
|
||||
"pageInfo": {"hasNextPage": False, "endCursor": "edge-cursor"},
|
||||
}
|
||||
},
|
||||
"rateLimit": {"cost": 1, "remaining": 4999, "resetAt": "2026-01-01T01:00:00Z"},
|
||||
}
|
||||
}
|
||||
runner = FakeRunner(
|
||||
subprocess.CompletedProcess([], 0, json.dumps(payload), "")
|
||||
)
|
||||
gateway = star_history.GhGraphQLGateway(runner)
|
||||
|
||||
result = gateway.fetch_stargazer_page("previous-page")
|
||||
|
||||
arguments = "\n".join(runner.arguments)
|
||||
self.assertEqual(result.total_count, 1)
|
||||
self.assertIn("first: 100", arguments)
|
||||
self.assertIn("after: $after", arguments)
|
||||
self.assertIn("after=previous-page", arguments)
|
||||
expected_query = """\
|
||||
query StarTimes($owner: String!, $name: String!, $after: String) {
|
||||
repository(owner: $owner, name: $name) {
|
||||
stargazers(
|
||||
first: 100
|
||||
after: $after
|
||||
orderBy: {field: STARRED_AT, direction: DESC}
|
||||
) {
|
||||
totalCount
|
||||
edges { cursor starredAt }
|
||||
pageInfo { hasNextPage endCursor }
|
||||
}
|
||||
}
|
||||
rateLimit { cost remaining resetAt }
|
||||
}
|
||||
"""
|
||||
self.assertEqual(star_history.GRAPHQL_QUERY, expected_query)
|
||||
for forbidden in (
|
||||
"nodes",
|
||||
" node ",
|
||||
" login ",
|
||||
" avatar ",
|
||||
" databaseId ",
|
||||
" email ",
|
||||
" url ",
|
||||
TOKEN_SENTINEL,
|
||||
):
|
||||
self.assertNotIn(forbidden, arguments)
|
||||
|
||||
def test_graphql_gateway_rejects_malformed_nested_shape(self):
|
||||
payload = {
|
||||
"data": {
|
||||
"repository": {
|
||||
"stargazers": {
|
||||
"totalCount": 0,
|
||||
"edges": [],
|
||||
"pageInfo": [],
|
||||
}
|
||||
},
|
||||
"rateLimit": {"remaining": 4999},
|
||||
}
|
||||
}
|
||||
gateway = star_history.GhGraphQLGateway(
|
||||
FakeRunner(subprocess.CompletedProcess([], 0, json.dumps(payload), ""))
|
||||
)
|
||||
|
||||
with self.assertRaises(star_history.StarHistoryError):
|
||||
gateway.fetch_stargazer_page(None)
|
||||
|
||||
def test_graphql_gateway_does_not_echo_failed_command_stderr(self):
|
||||
runner = FakeRunner(
|
||||
subprocess.CompletedProcess([], 1, "", f"failure {TOKEN_SENTINEL}")
|
||||
)
|
||||
gateway = star_history.GhGraphQLGateway(runner)
|
||||
with self.assertRaises(star_history.StarHistoryError) as captured:
|
||||
gateway.fetch_stargazer_page(None)
|
||||
self.assertNotIn(TOKEN_SENTINEL, str(captured.exception))
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
Loading…
Reference in New Issue