mirror of
https://github.com/prdlk/leetcode.git
synced 2026-09-16 23:16:26 +00:00
Add SRS automation: scheduler, logger, weekly gates
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"schemaVersion": 1,
|
||||||
|
"label": "gate pass rate",
|
||||||
|
"message": "no gates yet",
|
||||||
|
"color": "lightgrey"
|
||||||
|
}
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"2026-08-17": 3,
|
||||||
|
"2026-08-18": 4,
|
||||||
|
"2026-08-24": 5,
|
||||||
|
"2026-08-25": 6,
|
||||||
|
"2026-08-26": 7,
|
||||||
|
"2026-08-27": 8,
|
||||||
|
"2026-08-28": 9,
|
||||||
|
"2026-08-31": 10,
|
||||||
|
"2026-09-01": 11,
|
||||||
|
"2026-09-02": 12,
|
||||||
|
"2026-09-03": 13,
|
||||||
|
"2026-09-04": 14,
|
||||||
|
"2026-09-07": 15,
|
||||||
|
"2026-09-08": 16,
|
||||||
|
"2026-09-09": 17,
|
||||||
|
"2026-09-10": 18,
|
||||||
|
"2026-09-11": 19,
|
||||||
|
"2026-09-14": 20,
|
||||||
|
"2026-09-15": 21,
|
||||||
|
"2026-09-16": 22,
|
||||||
|
"2026-09-17": 23,
|
||||||
|
"2026-09-18": 24,
|
||||||
|
"2026-09-21": 25,
|
||||||
|
"2026-09-22": 26
|
||||||
|
}
|
||||||
@@ -0,0 +1,548 @@
|
|||||||
|
{
|
||||||
|
"problems": {
|
||||||
|
"1": {
|
||||||
|
"issue": 29,
|
||||||
|
"topic": 3,
|
||||||
|
"difficulty": "easy",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"issue": 66,
|
||||||
|
"topic": 7,
|
||||||
|
"difficulty": "hard",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-09-29",
|
||||||
|
"defer_until": "2026-09-29",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"11": {
|
||||||
|
"issue": 44,
|
||||||
|
"topic": 5,
|
||||||
|
"difficulty": "medium",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"issue": 43,
|
||||||
|
"topic": 5,
|
||||||
|
"difficulty": "medium",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"33": {
|
||||||
|
"issue": 60,
|
||||||
|
"topic": 7,
|
||||||
|
"difficulty": "medium",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"42": {
|
||||||
|
"issue": 49,
|
||||||
|
"topic": 5,
|
||||||
|
"difficulty": "hard",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-09-28",
|
||||||
|
"defer_until": "2026-09-28",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"49": {
|
||||||
|
"issue": 30,
|
||||||
|
"topic": 3,
|
||||||
|
"difficulty": "medium",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"52": {
|
||||||
|
"issue": 156,
|
||||||
|
"topic": 22,
|
||||||
|
"difficulty": "hard",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-10-03",
|
||||||
|
"defer_until": "2026-10-03",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"72": {
|
||||||
|
"issue": 171,
|
||||||
|
"topic": 24,
|
||||||
|
"difficulty": "medium",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-10-03",
|
||||||
|
"defer_until": "2026-10-03",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"76": {
|
||||||
|
"issue": 57,
|
||||||
|
"topic": 6,
|
||||||
|
"difficulty": "hard",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-09-28",
|
||||||
|
"defer_until": "2026-09-28",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"84": {
|
||||||
|
"issue": 76,
|
||||||
|
"topic": 9,
|
||||||
|
"difficulty": "hard",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-09-30",
|
||||||
|
"defer_until": "2026-09-30",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"97": {
|
||||||
|
"issue": 172,
|
||||||
|
"topic": 24,
|
||||||
|
"difficulty": "medium",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-10-04",
|
||||||
|
"defer_until": "2026-10-04",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"121": {
|
||||||
|
"issue": 50,
|
||||||
|
"topic": 6,
|
||||||
|
"difficulty": "easy",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"124": {
|
||||||
|
"issue": 105,
|
||||||
|
"topic": 13,
|
||||||
|
"difficulty": "hard",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-09-30",
|
||||||
|
"defer_until": "2026-09-30",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"125": {
|
||||||
|
"issue": 41,
|
||||||
|
"topic": 5,
|
||||||
|
"difficulty": "easy",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"167": {
|
||||||
|
"issue": 42,
|
||||||
|
"topic": 5,
|
||||||
|
"difficulty": "medium",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"212": {
|
||||||
|
"issue": 129,
|
||||||
|
"topic": 17,
|
||||||
|
"difficulty": "hard",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-10-02",
|
||||||
|
"defer_until": "2026-10-02",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"217": {
|
||||||
|
"issue": 27,
|
||||||
|
"topic": 3,
|
||||||
|
"difficulty": "easy",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"221": {
|
||||||
|
"issue": 173,
|
||||||
|
"topic": 24,
|
||||||
|
"difficulty": "medium",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-10-04",
|
||||||
|
"defer_until": "2026-10-04",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"224": {
|
||||||
|
"issue": 71,
|
||||||
|
"topic": 8,
|
||||||
|
"difficulty": "hard",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-09-29",
|
||||||
|
"defer_until": "2026-09-29",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"238": {
|
||||||
|
"issue": 37,
|
||||||
|
"topic": 4,
|
||||||
|
"difficulty": "medium",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"242": {
|
||||||
|
"issue": 28,
|
||||||
|
"topic": 3,
|
||||||
|
"difficulty": "easy",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"269": {
|
||||||
|
"issue": 145,
|
||||||
|
"topic": 20,
|
||||||
|
"difficulty": "hard",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-10-02",
|
||||||
|
"defer_until": "2026-10-02",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"295": {
|
||||||
|
"issue": 125,
|
||||||
|
"topic": 16,
|
||||||
|
"difficulty": "hard",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-10-01",
|
||||||
|
"defer_until": "2026-10-01",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"303": {
|
||||||
|
"issue": 36,
|
||||||
|
"topic": 4,
|
||||||
|
"difficulty": "easy",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"347": {
|
||||||
|
"issue": 31,
|
||||||
|
"topic": 3,
|
||||||
|
"difficulty": "medium",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"383": {
|
||||||
|
"issue": 32,
|
||||||
|
"topic": 3,
|
||||||
|
"difficulty": "easy",
|
||||||
|
"set": "optional",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"392": {
|
||||||
|
"issue": 45,
|
||||||
|
"topic": 5,
|
||||||
|
"difficulty": "easy",
|
||||||
|
"set": "optional",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"502": {
|
||||||
|
"issue": 126,
|
||||||
|
"topic": 16,
|
||||||
|
"difficulty": "hard",
|
||||||
|
"set": "deferred",
|
||||||
|
"stage": "new",
|
||||||
|
"next_review": "2026-10-01",
|
||||||
|
"defer_until": "2026-10-01",
|
||||||
|
"history": []
|
||||||
|
},
|
||||||
|
"560": {
|
||||||
|
"issue": 38,
|
||||||
|
"topic": 4,
|
||||||
|
"difficulty": "medium",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"704": {
|
||||||
|
"issue": 58,
|
||||||
|
"topic": 7,
|
||||||
|
"difficulty": "easy",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"875": {
|
||||||
|
"issue": 59,
|
||||||
|
"topic": 7,
|
||||||
|
"difficulty": "medium",
|
||||||
|
"set": "core",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"977": {
|
||||||
|
"issue": 46,
|
||||||
|
"topic": 5,
|
||||||
|
"difficulty": "easy",
|
||||||
|
"set": "optional",
|
||||||
|
"solved_on": "2026-08-24",
|
||||||
|
"stage": "+2",
|
||||||
|
"next_review": "2026-08-26",
|
||||||
|
"history": [
|
||||||
|
{
|
||||||
|
"date": "2026-08-24",
|
||||||
|
"kind": "first",
|
||||||
|
"result": "pass"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"topics": {
|
||||||
|
"3": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"4": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"5": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"6": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"7": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"8": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"9": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"10": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"11": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"12": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"13": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"14": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"15": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"16": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"17": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"18": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"19": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"20": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"21": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"22": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"23": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"24": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"25": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
},
|
||||||
|
"26": {
|
||||||
|
"misses": 0,
|
||||||
|
"boost": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"drill_pool_used": [],
|
||||||
|
"gates": []
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
name: Gate
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 12 * * 6" # Saturday 8 AM ET — create this week's gate
|
||||||
|
workflow_dispatch:
|
||||||
|
issues:
|
||||||
|
types: [closed] # grade a gate when it closes
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
# Shared with srs-scheduler / srs-logger / srs-setup: never race on srs.json.
|
||||||
|
concurrency:
|
||||||
|
group: srs-state
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
create:
|
||||||
|
# Trigger A: the only issue-creating workflow in the SRS. The script
|
||||||
|
# no-ops when this week's gate already exists.
|
||||||
|
if: github.event_name != 'issues'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- run: bun scripts/srs-gate.ts create
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Commit state
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add .github/srs
|
||||||
|
git diff --cached --quiet && { echo "no state change"; exit 0; }
|
||||||
|
git commit -m "srs: create weekly gate [skip ci]"
|
||||||
|
for i in 1 2 3; do git push && exit 0; git pull --rebase; done
|
||||||
|
git push
|
||||||
|
|
||||||
|
close:
|
||||||
|
# Trigger B: grade, log, boost, badge.
|
||||||
|
if: >-
|
||||||
|
github.event_name == 'issues' &&
|
||||||
|
contains(github.event.issue.labels.*.name, 'gate')
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- run: bun scripts/srs-gate.ts close
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|
||||||
|
- name: Commit state
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add .github/srs docs/gate-log.md
|
||||||
|
git diff --cached --quiet && { echo "no state change"; exit 0; }
|
||||||
|
git commit -m "srs: grade gate #${{ github.event.issue.number }} [skip ci]"
|
||||||
|
for i in 1 2 3; do git push && exit 0; git pull --rebase; done
|
||||||
|
git push
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
name: SRS Logger
|
||||||
|
|
||||||
|
on:
|
||||||
|
issue_comment:
|
||||||
|
types: [created]
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
# Shared with srs-scheduler / gate / srs-setup: never race on srs.json.
|
||||||
|
concurrency:
|
||||||
|
group: srs-state
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
log:
|
||||||
|
# Owner-only, and only on the daily brief or a gate issue. Put every
|
||||||
|
# /done line in ONE comment — queued duplicate runs may be superseded.
|
||||||
|
if: >-
|
||||||
|
github.event.comment.user.login == github.repository_owner &&
|
||||||
|
(github.event.issue.title == '📋 Today' ||
|
||||||
|
contains(github.event.issue.labels.*.name, 'gate'))
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
- run: bun scripts/srs-logger.ts
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
PROJECT_PAT: ${{ secrets.PROJECT_PAT }}
|
||||||
|
|
||||||
|
- name: Commit state
|
||||||
|
run: |
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git add .github/srs
|
||||||
|
git diff --cached --quiet && { echo "no state change"; exit 0; }
|
||||||
|
git commit -m "srs: log results from #${{ github.event.issue.number }} [skip ci]"
|
||||||
|
for i in 1 2 3; do git push && exit 0; git pull --rebase; done
|
||||||
|
git push
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
name: SRS Scheduler
|
||||||
|
|
||||||
|
on:
|
||||||
|
schedule:
|
||||||
|
- cron: "0 10 * * *" # 6 AM ET — before the day starts
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: write
|
||||||
|
|
||||||
|
# Shared with srs-logger / gate / srs-setup: never race on srs.json.
|
||||||
|
concurrency:
|
||||||
|
group: srs-state
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
brief:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
# Stateless: reads srs.json + schedule.json, rewrites the pinned
|
||||||
|
# 📋 Today issue, mirrors Target Date. Commits nothing.
|
||||||
|
- run: bun scripts/srs-scheduler.ts
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
PROJECT_PAT: ${{ secrets.PROJECT_PAT }}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
name: SRS Setup
|
||||||
|
|
||||||
|
on:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
issues: read
|
||||||
|
|
||||||
|
# Shared with the other SRS workflows: never race on srs.json.
|
||||||
|
concurrency:
|
||||||
|
group: srs-state
|
||||||
|
cancel-in-progress: false
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
setup:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- uses: oven-sh/setup-bun@v2
|
||||||
|
|
||||||
|
# Verifies PROJECT_PAT against the user project, creates the SRS
|
||||||
|
# single-select fields when missing, and re-mirrors every laddered
|
||||||
|
# problem's Target Date / SRS Stage / First Attempt. Idempotent.
|
||||||
|
- run: bun scripts/srs-setup.ts
|
||||||
|
env:
|
||||||
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
PROJECT_PAT: ${{ secrets.PROJECT_PAT }}
|
||||||
@@ -5,6 +5,7 @@
|
|||||||
<p align="center">
|
<p align="center">
|
||||||
<a href="https://x.com/basedprad"><img alt="follow" src="https://shieldcn.dev/x/follow/basedprad.svg" /></a>
|
<a href="https://x.com/basedprad"><img alt="follow" src="https://shieldcn.dev/x/follow/basedprad.svg" /></a>
|
||||||
<a href="https://lc.prad.nu"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
|
<a href="https://lc.prad.nu"><img alt="badge" src="https://shieldcn.dev/badge/Read-Documentation.svg?logo=lu%3ABook" /></a>
|
||||||
|
<a href="docs/gate-log.md"><img alt="gate pass rate" src="https://img.shields.io/endpoint?url=https%3A%2F%2Fraw.githubusercontent.com%2Fprdlk%2Fleetcode%2Fmain%2F.github%2Fsrs%2Fbadge.json" /></a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
# Gate log
|
||||||
|
|
||||||
|
Weekly topic-blind gates. Target: 70% first-attempt pass rate. Rows appended
|
||||||
|
automatically by `gate.yml` when a gate issue closes.
|
||||||
|
|
||||||
|
| Week | Date | Problems | First-attempt rate | Result |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
Executable
+261
@@ -0,0 +1,261 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Weekly gate: create Saturday's topic-blind quiz, and grade it on close.
|
||||||
|
*
|
||||||
|
* bun scripts/srs-gate.ts create # Trigger A (cron Sat / dispatch)
|
||||||
|
* bun scripts/srs-gate.ts close --issue N # Trigger B (issues: closed)
|
||||||
|
*
|
||||||
|
* create — samples ONE unsolved `set:optional` problem per topic scheduled
|
||||||
|
* THIS week (daily drills only ever draw from earlier weeks, so every gate
|
||||||
|
* problem is a first encounter). Seeded by ISO week number: re-runs pick the
|
||||||
|
* same problems, and an existing `Gate — Week N` issue makes the run a no-op.
|
||||||
|
* Exhausted optional pools fall back to an unsolved core problem, and the
|
||||||
|
* body says so. Boost flags + miss counters from LAST week's gate are cleared
|
||||||
|
* here — the boosted topics had their remedial week.
|
||||||
|
*
|
||||||
|
* close — reads this gate's `/done` results (recorded by srs-logger) from
|
||||||
|
* srs.json, computes the first-attempt pass rate (unlogged problems count as
|
||||||
|
* failures — skipping a gate problem is not a pass), appends docs/gate-log.md,
|
||||||
|
* comments a summary, sets `boost: true` on every topic that failed a gate
|
||||||
|
* problem or accumulated ≥ 2 drill misses, and refreshes the README badge
|
||||||
|
* endpoint at .github/srs/badge.json.
|
||||||
|
*/
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
|
||||||
|
import { github } from "./github.ts";
|
||||||
|
import {
|
||||||
|
campaignWeek,
|
||||||
|
fetchCatalog,
|
||||||
|
isoWeek,
|
||||||
|
loadSchedule,
|
||||||
|
loadState,
|
||||||
|
rng,
|
||||||
|
sample,
|
||||||
|
saveState,
|
||||||
|
todayET,
|
||||||
|
} from "./srs.ts";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "..");
|
||||||
|
const GATE_LOG = process.env.SRS_GATE_LOG ?? join(ROOT, "docs", "gate-log.md");
|
||||||
|
const BADGE = process.env.SRS_BADGE ?? join(ROOT, ".github", "srs", "badge.json");
|
||||||
|
const PASS_TARGET = 70; // percent, first-attempt
|
||||||
|
|
||||||
|
const { positionals, values } = parseArgs({
|
||||||
|
allowPositionals: true,
|
||||||
|
options: { issue: { type: "string" } },
|
||||||
|
});
|
||||||
|
const mode = positionals[0];
|
||||||
|
|
||||||
|
const gh = await github();
|
||||||
|
const state = await loadState();
|
||||||
|
const today = todayET();
|
||||||
|
|
||||||
|
// ── Trigger A: create this week's gate ───────────────────────────
|
||||||
|
|
||||||
|
async function create(): Promise<void> {
|
||||||
|
const week = campaignWeek(today);
|
||||||
|
const title = `Gate — Week ${week}`;
|
||||||
|
|
||||||
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=gate&state=all`)) {
|
||||||
|
const issue = raw as { number: number; title: string };
|
||||||
|
if (issue.title === title) {
|
||||||
|
console.log(`${title} already exists (#${issue.number}); nothing to do`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedule = await loadSchedule();
|
||||||
|
const topics = Object.entries(schedule)
|
||||||
|
.filter(([date]) => campaignWeek(date) === week)
|
||||||
|
.sort(([a], [b]) => (a < b ? -1 : 1))
|
||||||
|
.map(([, topic]) => topic);
|
||||||
|
if (topics.length === 0) {
|
||||||
|
console.log(`no topics scheduled in week ${week}; no gate to create`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = await fetchCatalog(gh);
|
||||||
|
const random = rng(`gate-${isoWeek(today)}`);
|
||||||
|
const picks: { lc: number; fallback: boolean }[] = [];
|
||||||
|
for (const topic of topics) {
|
||||||
|
const fresh = (set: string) =>
|
||||||
|
[...catalog.problems.values()]
|
||||||
|
.filter(
|
||||||
|
(p) =>
|
||||||
|
p.topic === topic &&
|
||||||
|
p.set === set &&
|
||||||
|
p.open &&
|
||||||
|
!state.problems[p.lc] &&
|
||||||
|
!state.drill_pool_used.includes(p.lc),
|
||||||
|
)
|
||||||
|
.sort((a, b) => a.lc - b.lc);
|
||||||
|
const optional = fresh("optional");
|
||||||
|
if (optional.length > 0) {
|
||||||
|
picks.push({ lc: sample(optional, 1, random)[0]!.lc, fallback: false });
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const core = fresh("core");
|
||||||
|
if (core.length > 0) picks.push({ lc: sample(core, 1, random)[0]!.lc, fallback: true });
|
||||||
|
// Both pools exhausted: the topic is fully solved; nothing to quiz.
|
||||||
|
}
|
||||||
|
if (picks.length === 0) {
|
||||||
|
console.log(`every pool for week ${week} is exhausted; no gate to create`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lines = picks.map(({ lc, fallback }) => {
|
||||||
|
const p = catalog.problems.get(lc)!;
|
||||||
|
return `- [LC ${p.lc}](${p.url}) — ${p.difficulty}${fallback ? " *(core fallback — optional pool exhausted)*" : ""}`;
|
||||||
|
});
|
||||||
|
const body = [
|
||||||
|
`**Target: ${PASS_TARGET}% first-attempt pass rate. Timed. Narrate out loud.**`,
|
||||||
|
"",
|
||||||
|
...lines,
|
||||||
|
"",
|
||||||
|
"Log with `/done <number> <pass|fail>` comments, then close this issue.",
|
||||||
|
].join("\n");
|
||||||
|
|
||||||
|
const milestone = catalog.topics.get(topics[0]!)?.milestone;
|
||||||
|
const created = (await gh.api(`/repos/${gh.repo}/issues`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ title, body, labels: ["gate"], milestone }),
|
||||||
|
})) as { number: number };
|
||||||
|
|
||||||
|
state.gates.push({
|
||||||
|
week,
|
||||||
|
issue: created.number,
|
||||||
|
date: today,
|
||||||
|
problems: picks.map((p) => p.lc),
|
||||||
|
fallbacks: picks.filter((p) => p.fallback).map((p) => p.lc),
|
||||||
|
results: {},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Last week's boosted topics had their remedial week: clear flags/counters.
|
||||||
|
for (const t of Object.values(state.topics)) {
|
||||||
|
if (t.boost) {
|
||||||
|
t.boost = false;
|
||||||
|
t.misses = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await saveState(state);
|
||||||
|
console.log(`created #${created.number} ${title}: ${picks.map((p) => `LC ${p.lc}`).join(", ")}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Trigger B: grade a closed gate ───────────────────────────────
|
||||||
|
|
||||||
|
async function close(issueNumber: number): Promise<void> {
|
||||||
|
const gate = state.gates.find((g) => g.issue === issueNumber);
|
||||||
|
if (!gate) {
|
||||||
|
console.log(`issue #${issueNumber} has no gate record in srs.json; ignoring`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (gate.rate !== undefined) {
|
||||||
|
console.log(`gate #${issueNumber} already graded (${gate.rate}%); nothing to do`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const catalog = await fetchCatalog(gh);
|
||||||
|
const passes = gate.problems.filter((lc) => gate.results[lc] === "pass");
|
||||||
|
const unlogged = gate.problems.filter((lc) => gate.results[lc] === undefined);
|
||||||
|
const rate = Math.round((passes.length / gate.problems.length) * 100);
|
||||||
|
gate.rate = rate;
|
||||||
|
const passed = rate >= PASS_TARGET;
|
||||||
|
|
||||||
|
// Boost: explicit gate failures, plus topics with ≥ 2 recognition misses.
|
||||||
|
const boosted: number[] = [];
|
||||||
|
for (const lc of gate.problems) {
|
||||||
|
if (gate.results[lc] === "fail") {
|
||||||
|
const topic = catalog.problems.get(lc)?.topic;
|
||||||
|
if (topic !== undefined) {
|
||||||
|
(state.topics[topic] ??= { misses: 0, boost: false }).boost = true;
|
||||||
|
boosted.push(topic);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (const [topic, t] of Object.entries(state.topics)) {
|
||||||
|
if (t.misses >= 2 && !t.boost) {
|
||||||
|
t.boost = true;
|
||||||
|
boosted.push(Number(topic));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gate log row.
|
||||||
|
const log = await Bun.file(GATE_LOG).text();
|
||||||
|
const problems = gate.problems.map((lc) => `LC ${lc}`).join(" · ");
|
||||||
|
await Bun.write(
|
||||||
|
GATE_LOG,
|
||||||
|
`${log.trimEnd()}\n| ${gate.week} | ${today} | ${problems} | ${rate}% | ${passed ? "pass" : "fail"} |\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// Badge endpoint (shields.io schema).
|
||||||
|
await Bun.write(
|
||||||
|
BADGE,
|
||||||
|
`${JSON.stringify(
|
||||||
|
{
|
||||||
|
schemaVersion: 1,
|
||||||
|
label: "gate pass rate",
|
||||||
|
message: `${rate}%`,
|
||||||
|
color: passed ? "brightgreen" : "red",
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2,
|
||||||
|
)}\n`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await saveState(state);
|
||||||
|
|
||||||
|
const summary = [
|
||||||
|
`## Gate — Week ${gate.week}: **${rate}%** first-attempt (target ${PASS_TARGET}%) — ${passed ? "✅ pass" : "❌ fail"}`,
|
||||||
|
"",
|
||||||
|
...gate.problems.map((lc) => {
|
||||||
|
const r = gate.results[lc];
|
||||||
|
return `- LC ${lc} — ${r === "pass" ? "✅ pass" : r === "fail" ? "❌ fail" : "⬜ not logged (counted as fail)"}`;
|
||||||
|
}),
|
||||||
|
"",
|
||||||
|
unlogged.length ? `${unlogged.length} problem(s) were never logged.` : "",
|
||||||
|
boosted.length
|
||||||
|
? `Boosted topics for next week's drills: ${[...new Set(boosted)].map((t) => `#${t}`).join(", ")}.`
|
||||||
|
: "No topics boosted.",
|
||||||
|
"",
|
||||||
|
"Logged to `docs/gate-log.md`.",
|
||||||
|
]
|
||||||
|
.filter((l) => l !== "")
|
||||||
|
.join("\n");
|
||||||
|
await gh.api(`/repos/${gh.repo}/issues/${issueNumber}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ body: summary }),
|
||||||
|
});
|
||||||
|
console.log(`graded gate #${issueNumber}: ${rate}% (${passed ? "pass" : "fail"})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── dispatch ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async function eventIssueNumber(): Promise<number> {
|
||||||
|
if (values.issue) return Number(values.issue);
|
||||||
|
if (!process.env.GITHUB_EVENT_PATH) return NaN;
|
||||||
|
const event: unknown = JSON.parse(await Bun.file(process.env.GITHUB_EVENT_PATH).text());
|
||||||
|
if (
|
||||||
|
event &&
|
||||||
|
typeof event === "object" &&
|
||||||
|
"issue" in event &&
|
||||||
|
event.issue &&
|
||||||
|
typeof event.issue === "object" &&
|
||||||
|
"number" in event.issue &&
|
||||||
|
typeof event.issue.number === "number"
|
||||||
|
) {
|
||||||
|
return event.issue.number;
|
||||||
|
}
|
||||||
|
return NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === "create") {
|
||||||
|
await create();
|
||||||
|
} else if (mode === "close") {
|
||||||
|
const issueNumber = await eventIssueNumber();
|
||||||
|
if (!Number.isFinite(issueNumber)) throw new Error("close mode needs --issue N or an event");
|
||||||
|
await close(issueNumber);
|
||||||
|
} else {
|
||||||
|
throw new Error(`usage: srs-gate.ts create | close --issue N (got "${mode}")`);
|
||||||
|
}
|
||||||
Executable
+226
@@ -0,0 +1,226 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Process `/done <lc> <pass|fail>` comments on the `📋 Today` brief and on
|
||||||
|
* gate issues, and move problems along the interval ladder.
|
||||||
|
*
|
||||||
|
* Transitions (see srs.ts):
|
||||||
|
* - pass: new → +2 → +5 → +10 → retired; next_review = today + new interval.
|
||||||
|
* - fail: back to +2, due in 2 days.
|
||||||
|
* - First-ever log of a problem enters the ladder at +2 either way; a failed
|
||||||
|
* blind drill additionally increments its topic's recognition-miss counter
|
||||||
|
* (a signal the gate's boost logic reads). Drills join drill_pool_used so
|
||||||
|
* they are never served twice.
|
||||||
|
* - A clean first PASS also closes the problem's sub-issue.
|
||||||
|
*
|
||||||
|
* Feedback is never silent: a clean parse gets a 👍 reaction, anything else
|
||||||
|
* gets a reply naming the exact parse error. Only the repo owner is heard.
|
||||||
|
*
|
||||||
|
* Input: GITHUB_EVENT_PATH (issue_comment payload) in Actions, or
|
||||||
|
* --issue N --author LOGIN --comment-id N --body "..." locally.
|
||||||
|
*/
|
||||||
|
import { parseArgs } from "node:util";
|
||||||
|
|
||||||
|
import { github } from "./github.ts";
|
||||||
|
import { projectMirror, reportMirror } from "./srs-project.ts";
|
||||||
|
import {
|
||||||
|
type Attempt,
|
||||||
|
addDays,
|
||||||
|
fail,
|
||||||
|
fetchCatalog,
|
||||||
|
loadState,
|
||||||
|
pass,
|
||||||
|
saveState,
|
||||||
|
TODAY_TITLE,
|
||||||
|
todayET,
|
||||||
|
} from "./srs.ts";
|
||||||
|
|
||||||
|
const DRY = process.env.SRS_DRY === "1";
|
||||||
|
|
||||||
|
// ── event input ──────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Input {
|
||||||
|
issue: number;
|
||||||
|
issueTitle: string;
|
||||||
|
issueLabels: string[];
|
||||||
|
author: string;
|
||||||
|
commentId: number;
|
||||||
|
body: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readInput(): Promise<Input> {
|
||||||
|
if (process.env.GITHUB_EVENT_PATH) {
|
||||||
|
const event = JSON.parse(await Bun.file(process.env.GITHUB_EVENT_PATH).text());
|
||||||
|
return {
|
||||||
|
issue: event.issue.number,
|
||||||
|
issueTitle: event.issue.title,
|
||||||
|
issueLabels: (event.issue.labels ?? []).map((l: { name: string }) => l.name),
|
||||||
|
author: event.comment.user.login,
|
||||||
|
commentId: event.comment.id,
|
||||||
|
body: event.comment.body ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
const { values } = parseArgs({
|
||||||
|
options: {
|
||||||
|
issue: { type: "string" },
|
||||||
|
author: { type: "string" },
|
||||||
|
"comment-id": { type: "string" },
|
||||||
|
body: { type: "string" },
|
||||||
|
title: { type: "string" },
|
||||||
|
labels: { type: "string" },
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
issue: Number(values.issue),
|
||||||
|
issueTitle: values.title ?? TODAY_TITLE,
|
||||||
|
issueLabels: values.labels?.split(",") ?? [],
|
||||||
|
author: values.author ?? "",
|
||||||
|
commentId: Number(values["comment-id"] ?? 0),
|
||||||
|
body: values.body ?? "",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const gh = await github();
|
||||||
|
const input = await readInput();
|
||||||
|
const owner = gh.repo.split("/")[0]!;
|
||||||
|
|
||||||
|
if (input.author !== owner) {
|
||||||
|
console.log(`ignoring comment by @${input.author} (owner only)`);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
const isGate = input.issueLabels.includes("gate");
|
||||||
|
const isToday = input.issueTitle === TODAY_TITLE;
|
||||||
|
if (!isGate && !isToday) {
|
||||||
|
console.log("not the Today brief or a gate issue; ignoring");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── parse ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
interface Command {
|
||||||
|
lc: number;
|
||||||
|
result: "pass" | "fail";
|
||||||
|
}
|
||||||
|
|
||||||
|
const commands: Command[] = [];
|
||||||
|
const errors: string[] = [];
|
||||||
|
for (const raw of input.body.split("\n")) {
|
||||||
|
const line = raw.trim();
|
||||||
|
if (!line.startsWith("/done")) continue;
|
||||||
|
const m = line.match(/^\/done\s+(\d+)\s+(pass|fail)\s*$/);
|
||||||
|
if (m) commands.push({ lc: Number(m[1]), result: m[2] as Command["result"] });
|
||||||
|
else errors.push(`cannot parse \`${line}\` — expected \`/done <number> <pass|fail>\``);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (commands.length === 0 && errors.length === 0) {
|
||||||
|
console.log("no /done lines; nothing to do");
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── apply ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const state = await loadState();
|
||||||
|
const catalog = await fetchCatalog(gh);
|
||||||
|
const today = todayET();
|
||||||
|
const mirror = await projectMirror(owner, gh.repo);
|
||||||
|
const applied: string[] = [];
|
||||||
|
|
||||||
|
const gate = isGate ? state.gates.find((g) => g.issue === input.issue) : undefined;
|
||||||
|
if (isGate && !gate) errors.push(`no gate record for issue #${input.issue} in srs.json`);
|
||||||
|
|
||||||
|
for (const cmd of commands) {
|
||||||
|
const known = catalog.problems.get(cmd.lc);
|
||||||
|
const entry = state.problems[cmd.lc];
|
||||||
|
|
||||||
|
if (!entry && !known) {
|
||||||
|
errors.push(`LC ${cmd.lc} is not in the curriculum`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (gate && known && gate.problems.includes(cmd.lc)) {
|
||||||
|
gate.results[cmd.lc] = cmd.result;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!entry) {
|
||||||
|
// First-ever attempt: enters the ladder at +2 regardless of result — a
|
||||||
|
// pass earns a +2 review, a fail must be re-solved just as soon.
|
||||||
|
const kind: Attempt["kind"] = gate ? "gate" : known!.set === "optional" ? "drill" : "first";
|
||||||
|
const p: (typeof state.problems)[string] = {
|
||||||
|
issue: known!.issue,
|
||||||
|
topic: known!.topic,
|
||||||
|
difficulty: known!.difficulty.toLowerCase(),
|
||||||
|
set: known!.set,
|
||||||
|
solved_on: today,
|
||||||
|
stage: "+2",
|
||||||
|
next_review: addDays(today, 2),
|
||||||
|
history: [{ date: today, kind, result: cmd.result }],
|
||||||
|
};
|
||||||
|
state.problems[cmd.lc] = p;
|
||||||
|
if (kind === "drill") {
|
||||||
|
if (!state.drill_pool_used.includes(cmd.lc)) state.drill_pool_used.push(cmd.lc);
|
||||||
|
if (cmd.result === "fail") {
|
||||||
|
const t = (state.topics[known!.topic] ??= { misses: 0, boost: false });
|
||||||
|
t.misses++;
|
||||||
|
applied.push(`LC ${cmd.lc}: recognition miss — topic #${known!.topic} misses=${t.misses}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!DRY) {
|
||||||
|
await mirror.setFirstAttempt(known!.issue, cmd.result);
|
||||||
|
await mirror.setStage(known!.issue, "+2");
|
||||||
|
await mirror.setTargetDate(known!.issue, p.next_review!);
|
||||||
|
if (cmd.result === "pass" && known!.open) {
|
||||||
|
await gh.closeIssue(
|
||||||
|
known!.issue,
|
||||||
|
`First attempt passed (${kind}) — entering the review ladder at +2. Logged by \`srs-logger\`.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
applied.push(`LC ${cmd.lc}: first ${kind} ${cmd.result} → stage +2, review ${p.next_review}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already on the ladder: a review (or a gate/drill re-encounter).
|
||||||
|
if (entry.stage === "retired") {
|
||||||
|
errors.push(`LC ${cmd.lc} is already retired`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
entry.history.push({ date: today, kind: "review", result: cmd.result });
|
||||||
|
if (cmd.result === "pass") {
|
||||||
|
pass(entry, today);
|
||||||
|
} else {
|
||||||
|
fail(entry, today);
|
||||||
|
}
|
||||||
|
delete entry.defer_until; // once touched, it lives by next_review alone
|
||||||
|
if (!DRY) {
|
||||||
|
await mirror.setStage(entry.issue, entry.stage);
|
||||||
|
await mirror.setTargetDate(entry.issue, entry.next_review ?? null);
|
||||||
|
}
|
||||||
|
applied.push(
|
||||||
|
`LC ${cmd.lc}: review ${cmd.result} → stage ${entry.stage}` +
|
||||||
|
(entry.next_review ? `, review ${entry.next_review}` : " — retired 🎉"),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── persist + respond ────────────────────────────────────────────
|
||||||
|
|
||||||
|
if (!DRY) await saveState(state);
|
||||||
|
|
||||||
|
for (const line of applied) console.log(line);
|
||||||
|
for (const line of errors) console.log(`error: ${line}`);
|
||||||
|
|
||||||
|
if (!DRY && input.commentId) {
|
||||||
|
if (errors.length === 0) {
|
||||||
|
await gh.api(`/repos/${gh.repo}/issues/comments/${input.commentId}/reactions`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ content: "+1" }),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await gh.api(`/repos/${gh.repo}/issues/${input.issue}/comments`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
body: `Could not log everything:\n\n${errors.map((e) => `- ${e}`).join("\n")}${applied.length ? `\n\nApplied anyway:\n\n${applied.map((a) => `- ${a}`).join("\n")}` : ""}`,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
await reportMirror(mirror);
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
/**
|
||||||
|
* Best-effort mirror of SRS state into the user-level GitHub Project
|
||||||
|
* ("Interview Prep").
|
||||||
|
*
|
||||||
|
* The JSON state file is the truth; these fields only make the project's
|
||||||
|
* table and roadmap views double as a live review calendar. Every operation
|
||||||
|
* is wrapped: a failure records a warning and the run continues — a workflow
|
||||||
|
* must never lose an srs.json update because a GraphQL mutation failed.
|
||||||
|
*
|
||||||
|
* Auth: PROJECT_PAT (the default GITHUB_TOKEN cannot touch user projects),
|
||||||
|
* falling back to GH_TOKEN / `gh auth token` for local runs.
|
||||||
|
*
|
||||||
|
* Guardrail: only problem issues are ever passed in here, so topic rows'
|
||||||
|
* Target Date is never written.
|
||||||
|
*/
|
||||||
|
import { $ } from "bun";
|
||||||
|
|
||||||
|
const PROJECT_TITLE = "Interview Prep";
|
||||||
|
|
||||||
|
interface SelectField {
|
||||||
|
id: string;
|
||||||
|
options: Record<string, string>; // option name -> option id
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ProjectInfo {
|
||||||
|
id: string;
|
||||||
|
targetDate: string; // field id
|
||||||
|
srsStage: SelectField;
|
||||||
|
firstAttempt: SelectField;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProjectMirror {
|
||||||
|
/** Set (or clear, with null) a problem row's Target Date. */
|
||||||
|
setTargetDate(issue: number, date: string | null): Promise<void>;
|
||||||
|
setStage(issue: number, stage: string): Promise<void>;
|
||||||
|
setFirstAttempt(issue: number, result: string): Promise<void>;
|
||||||
|
/** Accumulated failures; print + step-summarize these, never throw. */
|
||||||
|
warnings: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveToken(): Promise<string> {
|
||||||
|
const env = process.env.PROJECT_PAT || process.env.GH_TOKEN || process.env.GITHUB_TOKEN;
|
||||||
|
if (env) return env;
|
||||||
|
return (await $`gh auth token`.text()).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function projectMirror(owner: string, repo: string): Promise<ProjectMirror> {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
let token = "";
|
||||||
|
let project: ProjectInfo | undefined;
|
||||||
|
const itemIds = new Map<number, string | undefined>();
|
||||||
|
|
||||||
|
async function graphql(query: string, variables: Record<string, unknown>): Promise<unknown> {
|
||||||
|
const res = await fetch("https://api.github.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
|
||||||
|
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
|
||||||
|
if (payload.errors?.length) {
|
||||||
|
throw new Error(payload.errors.map((e) => e.message).join("; "));
|
||||||
|
}
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Resolve project + field/option ids once; by name, never hardcoded. */
|
||||||
|
async function resolve(): Promise<ProjectInfo> {
|
||||||
|
if (project) return project;
|
||||||
|
token ||= await resolveToken();
|
||||||
|
if (!token) throw new Error("no PROJECT_PAT / token available");
|
||||||
|
|
||||||
|
const data = (await graphql(
|
||||||
|
`query($owner: String!, $title: String!) {
|
||||||
|
user(login: $owner) {
|
||||||
|
projectsV2(first: 10, query: $title) {
|
||||||
|
nodes {
|
||||||
|
id title
|
||||||
|
fields(first: 30) {
|
||||||
|
nodes {
|
||||||
|
... on ProjectV2FieldCommon { id name dataType }
|
||||||
|
... on ProjectV2SingleSelectField { id name options { id name } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ owner, title: PROJECT_TITLE },
|
||||||
|
)) as {
|
||||||
|
user: {
|
||||||
|
projectsV2: {
|
||||||
|
nodes: {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
fields: {
|
||||||
|
nodes: { id: string; name: string; options?: { id: string; name: string }[] }[];
|
||||||
|
};
|
||||||
|
}[];
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const node = data.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
||||||
|
if (!node) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
||||||
|
|
||||||
|
const select = (name: string): SelectField => {
|
||||||
|
const f = node.fields.nodes.find((n) => n.name === name);
|
||||||
|
if (!f?.options) throw new Error(`single-select field "${name}" missing — run srs-setup`);
|
||||||
|
return { id: f.id, options: Object.fromEntries(f.options.map((o) => [o.name, o.id])) };
|
||||||
|
};
|
||||||
|
const date = node.fields.nodes.find((n) => n.name === "Target Date");
|
||||||
|
if (!date) throw new Error(`date field "Target Date" missing from project`);
|
||||||
|
|
||||||
|
project = {
|
||||||
|
id: node.id,
|
||||||
|
targetDate: date.id,
|
||||||
|
srsStage: select("SRS Stage"),
|
||||||
|
firstAttempt: select("First Attempt"),
|
||||||
|
};
|
||||||
|
return project;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The issue's item id in THIS project (an issue can be in several). */
|
||||||
|
async function itemId(issue: number): Promise<string> {
|
||||||
|
if (itemIds.has(issue)) {
|
||||||
|
const cached = itemIds.get(issue);
|
||||||
|
if (!cached) throw new Error(`issue #${issue} is not in the project`);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
const info = await resolve();
|
||||||
|
const [repoOwner, repoName] = repo.split("/");
|
||||||
|
const data = (await graphql(
|
||||||
|
`query($owner: String!, $name: String!, $issue: Int!) {
|
||||||
|
repository(owner: $owner, name: $name) {
|
||||||
|
issue(number: $issue) {
|
||||||
|
projectItems(first: 10, includeArchived: true) {
|
||||||
|
nodes { id project { id } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ owner: repoOwner, name: repoName, issue },
|
||||||
|
)) as {
|
||||||
|
repository: { issue: { projectItems: { nodes: { id: string; project: { id: string } }[] } } };
|
||||||
|
};
|
||||||
|
const item = data.repository.issue.projectItems.nodes.find((n) => n.project.id === info.id);
|
||||||
|
itemIds.set(issue, item?.id);
|
||||||
|
if (!item) throw new Error(`issue #${issue} is not in the project`);
|
||||||
|
return item.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function setField(issue: number, fieldId: string, value: object): Promise<void> {
|
||||||
|
const info = await resolve();
|
||||||
|
await graphql(
|
||||||
|
`mutation($project: ID!, $item: ID!, $field: ID!, $value: ProjectV2FieldValue!) {
|
||||||
|
updateProjectV2ItemFieldValue(
|
||||||
|
input: { projectId: $project, itemId: $item, fieldId: $field, value: $value }
|
||||||
|
) { projectV2Item { id } }
|
||||||
|
}`,
|
||||||
|
{ project: info.id, item: await itemId(issue), field: fieldId, value },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearField(issue: number, fieldId: string): Promise<void> {
|
||||||
|
const info = await resolve();
|
||||||
|
await graphql(
|
||||||
|
`mutation($project: ID!, $item: ID!, $field: ID!) {
|
||||||
|
clearProjectV2ItemFieldValue(
|
||||||
|
input: { projectId: $project, itemId: $item, fieldId: $field }
|
||||||
|
) { projectV2Item { id } }
|
||||||
|
}`,
|
||||||
|
{ project: info.id, item: await itemId(issue), field: fieldId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run op; on failure record a warning instead of propagating. */
|
||||||
|
async function attempt(what: string, op: () => Promise<void>): Promise<void> {
|
||||||
|
try {
|
||||||
|
await op();
|
||||||
|
} catch (err) {
|
||||||
|
warnings.push(`${what}: ${err instanceof Error ? err.message : String(err)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
warnings,
|
||||||
|
setTargetDate: (issue, date) =>
|
||||||
|
attempt(`Target Date #${issue}`, async () => {
|
||||||
|
const info = await resolve();
|
||||||
|
if (date === null) await clearField(issue, info.targetDate);
|
||||||
|
else await setField(issue, info.targetDate, { date });
|
||||||
|
}),
|
||||||
|
setStage: (issue, stage) =>
|
||||||
|
attempt(`SRS Stage #${issue}`, async () => {
|
||||||
|
const info = await resolve();
|
||||||
|
const option = info.srsStage.options[stage];
|
||||||
|
if (!option) throw new Error(`no option "${stage}"`);
|
||||||
|
await setField(issue, info.srsStage.id, { singleSelectOptionId: option });
|
||||||
|
}),
|
||||||
|
setFirstAttempt: (issue, result) =>
|
||||||
|
attempt(`First Attempt #${issue}`, async () => {
|
||||||
|
const info = await resolve();
|
||||||
|
const option = info.firstAttempt.options[result];
|
||||||
|
if (!option) throw new Error(`no option "${result}"`);
|
||||||
|
await setField(issue, info.firstAttempt.id, { singleSelectOptionId: option });
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Print mirror warnings and surface them in the Actions run summary. */
|
||||||
|
export async function reportMirror(mirror: ProjectMirror): Promise<void> {
|
||||||
|
if (mirror.warnings.length === 0) return;
|
||||||
|
console.warn(`\nproject mirror: ${mirror.warnings.length} warning(s) — srs.json is the truth`);
|
||||||
|
for (const w of mirror.warnings) console.warn(` ⚠ ${w}`);
|
||||||
|
if (process.env.GITHUB_STEP_SUMMARY) {
|
||||||
|
await Bun.write(
|
||||||
|
process.env.GITHUB_STEP_SUMMARY,
|
||||||
|
`### ⚠ project mirror warnings\n\nsrs.json was updated; these project mutations failed:\n\n${mirror.warnings.map((w) => `- ${w}`).join("\n")}\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Executable
+201
@@ -0,0 +1,201 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* Rewrite the pinned `📋 Today` daily-brief issue from SRS state.
|
||||||
|
*
|
||||||
|
* Runs every day at 10:00 UTC (6 AM ET) and on demand. Stateless by design:
|
||||||
|
* it reads srs.json + schedule.json and rewrites one issue body, so running
|
||||||
|
* it twice in a day is a byte-identical no-op (drill picks are seeded by the
|
||||||
|
* date). Overflow needs no bookkeeping either — anything past the daily cap
|
||||||
|
* simply stays due and is the oldest entry tomorrow.
|
||||||
|
*
|
||||||
|
* Retrieval rules enforced here:
|
||||||
|
* - Review and drill lines carry number + difficulty only. Never the topic,
|
||||||
|
* never a link — recognizing the pattern unaided is the exercise.
|
||||||
|
* - Blind drills come only from optional pools of topics covered in EARLIER
|
||||||
|
* weeks; the current week's optional pool is reserved for Saturday's gate.
|
||||||
|
* - Boosted topics (failed gate problem / repeated drill misses) inject up to
|
||||||
|
* 2 extra drills, inside the same daily cap.
|
||||||
|
*
|
||||||
|
* bun scripts/srs-scheduler.ts # rewrite the live issue
|
||||||
|
* SRS_DRY=1 SRS_TODAY=2026-08-30 bun ... # print the body, touch nothing
|
||||||
|
*/
|
||||||
|
import { github } from "./github.ts";
|
||||||
|
import { projectMirror, reportMirror } from "./srs-project.ts";
|
||||||
|
import {
|
||||||
|
CAMPAIGN_DAYS,
|
||||||
|
type Catalog,
|
||||||
|
type State,
|
||||||
|
TODAY_TITLE,
|
||||||
|
campaignDay,
|
||||||
|
campaignWeek,
|
||||||
|
dueReviews,
|
||||||
|
fetchCatalog,
|
||||||
|
loadSchedule,
|
||||||
|
loadState,
|
||||||
|
prettyDate,
|
||||||
|
rng,
|
||||||
|
sample,
|
||||||
|
todayET,
|
||||||
|
weekdayOf,
|
||||||
|
} from "./srs.ts";
|
||||||
|
|
||||||
|
const DRY = process.env.SRS_DRY === "1";
|
||||||
|
const DAILY_CAP = 6;
|
||||||
|
|
||||||
|
const gh = await github();
|
||||||
|
const state = await loadState();
|
||||||
|
const schedule = await loadSchedule();
|
||||||
|
const today = todayET();
|
||||||
|
const week = campaignWeek(today);
|
||||||
|
|
||||||
|
// ── compose the body ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
/** Week in which a topic was (or will be) taught, from the schedule file. */
|
||||||
|
function topicWeek(topic: number): number | undefined {
|
||||||
|
for (const [date, t] of Object.entries(schedule)) {
|
||||||
|
if (t === topic) return campaignWeek(date);
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Drill {
|
||||||
|
lc: number;
|
||||||
|
difficulty: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickDrills(catalog: Catalog, budget: number): Drill[] {
|
||||||
|
// Optional problems from earlier weeks, unseen by both ladder and drills.
|
||||||
|
const pool = [...catalog.problems.values()]
|
||||||
|
.filter((p) => {
|
||||||
|
if (p.set !== "optional" || !p.open) return false;
|
||||||
|
if (state.problems[p.lc] || state.drill_pool_used.includes(p.lc)) return false;
|
||||||
|
const w = topicWeek(p.topic);
|
||||||
|
return w !== undefined && w < week;
|
||||||
|
})
|
||||||
|
.sort((a, b) => a.lc - b.lc); // stable base order for the seeded shuffle
|
||||||
|
|
||||||
|
const boosted = pool.filter((p) => state.topics[p.topic]?.boost);
|
||||||
|
const regular = pool.filter((p) => !state.topics[p.topic]?.boost);
|
||||||
|
|
||||||
|
const picks = [
|
||||||
|
...sample(boosted, Math.min(2, budget), rng(`boost-${today}`)),
|
||||||
|
...sample(regular, Math.min(2, Math.max(0, budget - Math.min(2, boosted.length))), rng(`drill-${today}`)),
|
||||||
|
].slice(0, budget);
|
||||||
|
return picks.map((p) => ({ lc: p.lc, difficulty: p.difficulty }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function composeBody(catalog: Catalog): string {
|
||||||
|
if (weekdayOf(today) === 0) {
|
||||||
|
return "Rest day. Nothing is due. Overdue reviews moved to Monday.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const due = dueReviews(state, today);
|
||||||
|
const reviews = due.slice(0, DAILY_CAP);
|
||||||
|
const carried = due.length - reviews.length;
|
||||||
|
const drills = pickDrills(catalog, DAILY_CAP - reviews.length);
|
||||||
|
|
||||||
|
const lines: string[] = [
|
||||||
|
`## ${prettyDate(today)} — Day ${campaignDay(today)}/${CAMPAIGN_DAYS} · Week ${week}`,
|
||||||
|
"",
|
||||||
|
];
|
||||||
|
|
||||||
|
const topicIssue = schedule[today];
|
||||||
|
const topic = topicIssue === undefined ? undefined : catalog.topics.get(topicIssue);
|
||||||
|
if (topic) {
|
||||||
|
const core = [...catalog.problems.values()]
|
||||||
|
.filter((p) => p.topic === topic.issue && p.set === "core")
|
||||||
|
.sort((a, b) => a.lc - b.lc)
|
||||||
|
.map((p) => `[LC ${p.lc}](${p.url})${p.open ? "" : " ✓"}`);
|
||||||
|
lines.push(`### New topic: ${topic.name} (#${topic.issue})`, `Core: ${core.join(" · ")}`, "");
|
||||||
|
} else {
|
||||||
|
lines.push("_No new topic today — reviews and drills only._", "");
|
||||||
|
}
|
||||||
|
|
||||||
|
lines.push(`### Reviews due (${reviews.length})`);
|
||||||
|
if (reviews.length) {
|
||||||
|
lines.push("Solve each from scratch. Do not open your old solution first.");
|
||||||
|
for (const [lc, p] of reviews) {
|
||||||
|
const difficulty = catalog.problems.get(Number(lc))?.difficulty ?? p.difficulty;
|
||||||
|
lines.push(`- LC ${lc} — ${difficulty} — stage ${p.stage}`);
|
||||||
|
}
|
||||||
|
if (carried > 0) lines.push(`\n${carried} more carried to tomorrow (cap ${DAILY_CAP}).`);
|
||||||
|
} else {
|
||||||
|
lines.push("None.");
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push(`### Blind drills (${drills.length})`);
|
||||||
|
if (drills.length) {
|
||||||
|
lines.push("No topic given. Name the pattern out loud before you code.");
|
||||||
|
for (const d of drills) lines.push(`- LC ${d.lc} — ${d.difficulty}`);
|
||||||
|
} else {
|
||||||
|
lines.push("None today.");
|
||||||
|
}
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
lines.push(
|
||||||
|
"### Log your results",
|
||||||
|
"Comment on this issue, one line per problem:",
|
||||||
|
"`/done 704 pass` · `/done 15 fail`",
|
||||||
|
"",
|
||||||
|
"### Rules",
|
||||||
|
"90-minute cap · core first, then reviews, then drills · close-out",
|
||||||
|
"ritual on every submit.",
|
||||||
|
);
|
||||||
|
return lines.join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── find / create / pin the Today issue ──────────────────────────
|
||||||
|
|
||||||
|
async function todayIssue(): Promise<{ number: number; created: boolean }> {
|
||||||
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues?state=open`)) {
|
||||||
|
const issue = raw as { number: number; title: string; pull_request?: unknown };
|
||||||
|
if (!issue.pull_request && issue.title === TODAY_TITLE) {
|
||||||
|
return { number: issue.number, created: false };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const created = (await gh.api(`/repos/${gh.repo}/issues`, {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({ title: TODAY_TITLE, body: "(initializing)" }),
|
||||||
|
})) as { number: number; node_id: string };
|
||||||
|
try {
|
||||||
|
await gh.api("/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
body: JSON.stringify({
|
||||||
|
query: `mutation($id: ID!) { pinIssue(input: { issueId: $id }) { issue { number } } }`,
|
||||||
|
variables: { id: created.node_id },
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`could not pin #${created.number}: ${err} — pin it by hand`);
|
||||||
|
}
|
||||||
|
return { number: created.number, created: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── run ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
const catalog = await fetchCatalog(gh);
|
||||||
|
const body = composeBody(catalog);
|
||||||
|
|
||||||
|
if (DRY) {
|
||||||
|
console.log(body);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
const issue = await todayIssue();
|
||||||
|
await gh.api(`/repos/${gh.repo}/issues/${issue.number}`, {
|
||||||
|
method: "PATCH",
|
||||||
|
body: JSON.stringify({ body }),
|
||||||
|
});
|
||||||
|
console.log(`${issue.created ? "created + pinned" : "rewrote"} #${issue.number} ${TODAY_TITLE}`);
|
||||||
|
|
||||||
|
// Mirror every due problem's next_review so the project's Target Date views
|
||||||
|
// stay a live review calendar (problem rows only — never topic rows).
|
||||||
|
const mirror = await projectMirror(gh.repo.split("/")[0]!, gh.repo);
|
||||||
|
if (weekdayOf(today) !== 0) {
|
||||||
|
for (const [, p] of dueReviews(state as State, today)) {
|
||||||
|
await mirror.setTargetDate(p.issue, p.next_review!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
await reportMirror(mirror);
|
||||||
|
console.log(body);
|
||||||
Executable
+146
@@ -0,0 +1,146 @@
|
|||||||
|
#!/usr/bin/env bun
|
||||||
|
/**
|
||||||
|
* One-shot (idempotent) SRS setup + PROJECT_PAT verification.
|
||||||
|
*
|
||||||
|
* 1. Proves the token can talk GraphQL (viewer login) — the acceptance test
|
||||||
|
* for the PROJECT_PAT repo secret when dispatched as a workflow.
|
||||||
|
* 2. Resolves the "Interview Prep" user project and its built-in Target Date
|
||||||
|
* field (which the SRS reuses — no custom date field is ever created).
|
||||||
|
* 3. Creates the `SRS Stage` and `First Attempt` single-selects when missing.
|
||||||
|
* 4. Mirrors every laddered problem's state into the project fields.
|
||||||
|
*
|
||||||
|
* Prints every resolved ID so they are on record in the run log.
|
||||||
|
*/
|
||||||
|
import { $ } from "bun";
|
||||||
|
|
||||||
|
import { github } from "./github.ts";
|
||||||
|
import { projectMirror, reportMirror } from "./srs-project.ts";
|
||||||
|
import { loadState } from "./srs.ts";
|
||||||
|
|
||||||
|
const PROJECT_TITLE = "Interview Prep";
|
||||||
|
|
||||||
|
const token =
|
||||||
|
process.env.PROJECT_PAT ||
|
||||||
|
process.env.GH_TOKEN ||
|
||||||
|
process.env.GITHUB_TOKEN ||
|
||||||
|
(await $`gh auth token`.text()).trim();
|
||||||
|
|
||||||
|
async function graphql(query: string, variables: Record<string, unknown> = {}): Promise<unknown> {
|
||||||
|
const res = await fetch("https://api.github.com/graphql", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
|
||||||
|
body: JSON.stringify({ query, variables }),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`graphql -> ${res.status} ${await res.text()}`);
|
||||||
|
const payload = (await res.json()) as { data?: unknown; errors?: { message: string }[] };
|
||||||
|
if (payload.errors?.length) throw new Error(payload.errors.map((e) => e.message).join("; "));
|
||||||
|
return payload.data;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. token check — hard fail here means PROJECT_PAT is missing or scopeless.
|
||||||
|
const viewerData = (await graphql(`{ viewer { login } }`)) as { viewer: { login: string } };
|
||||||
|
console.log(`token OK — authenticated as @${viewerData.viewer.login}`);
|
||||||
|
|
||||||
|
const gh = await github();
|
||||||
|
const owner = gh.repo.split("/")[0]!;
|
||||||
|
|
||||||
|
// 2. resolve the project and its fields.
|
||||||
|
interface FieldNode {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
dataType?: string;
|
||||||
|
options?: { id: string; name: string }[];
|
||||||
|
}
|
||||||
|
const projectData = (await graphql(
|
||||||
|
`query($owner: String!, $title: String!) {
|
||||||
|
user(login: $owner) {
|
||||||
|
projectsV2(first: 10, query: $title) {
|
||||||
|
nodes {
|
||||||
|
id number title
|
||||||
|
fields(first: 30) {
|
||||||
|
nodes {
|
||||||
|
... on ProjectV2FieldCommon { id name dataType }
|
||||||
|
... on ProjectV2SingleSelectField { id name options { id name } }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ owner, title: PROJECT_TITLE },
|
||||||
|
)) as {
|
||||||
|
user: {
|
||||||
|
projectsV2: { nodes: { id: string; number: number; title: string; fields: { nodes: FieldNode[] } }[] };
|
||||||
|
};
|
||||||
|
};
|
||||||
|
const project = projectData.user.projectsV2.nodes.find((n) => n.title === PROJECT_TITLE);
|
||||||
|
if (!project) throw new Error(`project "${PROJECT_TITLE}" not found for @${owner}`);
|
||||||
|
console.log(`project: "${project.title}" #${project.number} — ${project.id}`);
|
||||||
|
|
||||||
|
let fields = project.fields.nodes;
|
||||||
|
const targetDate = fields.find((f) => f.name === "Target Date" && f.dataType === "DATE");
|
||||||
|
if (!targetDate) {
|
||||||
|
throw new Error('the built-in "Target Date" field is missing — add it in the project UI');
|
||||||
|
}
|
||||||
|
console.log(`Target Date field: ${targetDate.id} (reused, not created)`);
|
||||||
|
|
||||||
|
// 3. create the two SRS single-selects when absent.
|
||||||
|
const WANTED: Record<string, { name: string; color: string; description: string }[]> = {
|
||||||
|
"SRS Stage": [
|
||||||
|
{ name: "new", color: "GRAY", description: "not yet on the ladder" },
|
||||||
|
{ name: "+2", color: "YELLOW", description: "review due 2 days after last solve" },
|
||||||
|
{ name: "+5", color: "ORANGE", description: "review due 5 days after last solve" },
|
||||||
|
{ name: "+10", color: "BLUE", description: "review due 10 days after last solve" },
|
||||||
|
{ name: "retired", color: "GREEN", description: "passed all three stages" },
|
||||||
|
],
|
||||||
|
"First Attempt": [
|
||||||
|
{ name: "pass", color: "GREEN", description: "first timed solve passed" },
|
||||||
|
{ name: "fail", color: "RED", description: "first timed solve failed" },
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const [name, options] of Object.entries(WANTED)) {
|
||||||
|
const existing = fields.find((f) => f.name === name);
|
||||||
|
if (existing) {
|
||||||
|
console.log(`${name} field: ${existing.id} (already exists)`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const optionsArg = options
|
||||||
|
.map((o) => `{name:"${o.name}",color:${o.color},description:"${o.description}"}`)
|
||||||
|
.join(",");
|
||||||
|
const created = (await graphql(
|
||||||
|
`mutation($project: ID!, $name: String!) {
|
||||||
|
createProjectV2Field(input: {
|
||||||
|
projectId: $project, dataType: SINGLE_SELECT, name: $name,
|
||||||
|
singleSelectOptions: [${optionsArg}]
|
||||||
|
}) {
|
||||||
|
projectV2Field { ... on ProjectV2SingleSelectField { id name } }
|
||||||
|
}
|
||||||
|
}`,
|
||||||
|
{ project: project.id, name },
|
||||||
|
)) as { createProjectV2Field: { projectV2Field: { id: string } } };
|
||||||
|
console.log(`${name} field: ${created.createProjectV2Field.projectV2Field.id} (created)`);
|
||||||
|
fields = [...fields, { id: created.createProjectV2Field.projectV2Field.id, name }];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. mirror every laddered problem into the project.
|
||||||
|
const state = await loadState();
|
||||||
|
const mirror = await projectMirror(owner, gh.repo);
|
||||||
|
let mirrored = 0;
|
||||||
|
for (const [lc, p] of Object.entries(state.problems)) {
|
||||||
|
await mirror.setStage(p.issue, p.stage);
|
||||||
|
await mirror.setTargetDate(p.issue, p.stage === "retired" ? null : (p.next_review ?? null));
|
||||||
|
const first = p.history[0];
|
||||||
|
if (first) await mirror.setFirstAttempt(p.issue, first.result);
|
||||||
|
mirrored++;
|
||||||
|
if (mirrored % 10 === 0) console.log(` mirrored ${mirrored} problems… (last LC ${lc})`);
|
||||||
|
}
|
||||||
|
console.log(`mirrored ${mirrored} laddered problems into the project`);
|
||||||
|
await reportMirror(mirror);
|
||||||
|
|
||||||
|
if (process.env.GITHUB_STEP_SUMMARY && mirror.warnings.length === 0) {
|
||||||
|
await Bun.write(
|
||||||
|
process.env.GITHUB_STEP_SUMMARY,
|
||||||
|
`### srs-setup ✅\n\n- token: @${viewerData.viewer.login}\n- project: ${project.id} (#${project.number})\n- Target Date: ${targetDate.id}\n- mirrored: ${mirrored} problems\n`,
|
||||||
|
);
|
||||||
|
}
|
||||||
+300
@@ -0,0 +1,300 @@
|
|||||||
|
/**
|
||||||
|
* Domain library for the spaced-repetition system (SRS).
|
||||||
|
*
|
||||||
|
* State model — `.github/srs/srs.json` is the single source of truth; the
|
||||||
|
* GitHub Project fields are a best-effort mirror (see srs-project.ts).
|
||||||
|
*
|
||||||
|
* The stage names the review a problem must pass NEXT: a problem at `+2` has
|
||||||
|
* a review due 2 days after its last clean solve. Passing advances
|
||||||
|
* new → +2 → +5 → +10 → retired; any failure resets to +2. `new` is reserved
|
||||||
|
* for deferred Hards that have never been solved — `defer_until` keeps them
|
||||||
|
* out of the queue until Sep 28.
|
||||||
|
*
|
||||||
|
* All dates are America/New_York calendar dates (YYYY-MM-DD): the cron fires
|
||||||
|
* at 10:00 UTC = 6 AM ET, and the campaign is lived in ET.
|
||||||
|
*/
|
||||||
|
import { join } from "node:path";
|
||||||
|
|
||||||
|
import type { GitHub } from "./github.ts";
|
||||||
|
|
||||||
|
const ROOT = join(import.meta.dir, "..");
|
||||||
|
|
||||||
|
// ── state ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type Stage = "new" | "+2" | "+5" | "+10" | "retired";
|
||||||
|
export type Result = "pass" | "fail";
|
||||||
|
|
||||||
|
export interface Attempt {
|
||||||
|
date: string;
|
||||||
|
/** first = learning-day solve, drill = blind drill, gate = gate problem. */
|
||||||
|
kind: "first" | "review" | "drill" | "gate";
|
||||||
|
result: Result;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ProblemState {
|
||||||
|
issue: number;
|
||||||
|
topic: number;
|
||||||
|
difficulty: string;
|
||||||
|
set: string;
|
||||||
|
solved_on?: string;
|
||||||
|
stage: Stage;
|
||||||
|
next_review?: string;
|
||||||
|
/** Deferred Hards stay out of the due queue until this date. */
|
||||||
|
defer_until?: string;
|
||||||
|
history: Attempt[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TopicState {
|
||||||
|
/** Blind-drill recognition misses since the last boost reset. */
|
||||||
|
misses: number;
|
||||||
|
/** Set at gate close; scheduler injects extra drills; next gate clears it. */
|
||||||
|
boost: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface GateState {
|
||||||
|
week: number;
|
||||||
|
issue: number;
|
||||||
|
date: string;
|
||||||
|
problems: number[];
|
||||||
|
/** Problems that fell back to the core set (optional pool exhausted). */
|
||||||
|
fallbacks: number[];
|
||||||
|
results: Record<string, Result>;
|
||||||
|
rate?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface State {
|
||||||
|
problems: Record<string, ProblemState>;
|
||||||
|
topics: Record<string, TopicState>;
|
||||||
|
drill_pool_used: number[];
|
||||||
|
gates: GateState[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export const STATE_PATH = process.env.SRS_STATE ?? join(ROOT, ".github", "srs", "srs.json");
|
||||||
|
export const SCHEDULE_PATH =
|
||||||
|
process.env.SRS_SCHEDULE ?? join(ROOT, ".github", "srs", "schedule.json");
|
||||||
|
|
||||||
|
export async function loadState(): Promise<State> {
|
||||||
|
return JSON.parse(await Bun.file(STATE_PATH).text());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function saveState(state: State): Promise<void> {
|
||||||
|
await Bun.write(STATE_PATH, `${JSON.stringify(state, null, 2)}\n`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Day (YYYY-MM-DD) → topic issue number. Human-edited when life happens. */
|
||||||
|
export async function loadSchedule(): Promise<Record<string, number>> {
|
||||||
|
return JSON.parse(await Bun.file(SCHEDULE_PATH).text());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── dates (America/New_York) ─────────────────────────────────────
|
||||||
|
|
||||||
|
/** Campaign day 1 — Monday of Phase I week 1. Day 56 = Oct 11. */
|
||||||
|
export const CAMPAIGN_START = "2026-08-17";
|
||||||
|
export const CAMPAIGN_DAYS = 56;
|
||||||
|
|
||||||
|
/** Today's ET calendar date; SRS_TODAY overrides for tests and reruns. */
|
||||||
|
export function todayET(): string {
|
||||||
|
if (process.env.SRS_TODAY) return process.env.SRS_TODAY;
|
||||||
|
return new Intl.DateTimeFormat("en-CA", {
|
||||||
|
timeZone: "America/New_York",
|
||||||
|
dateStyle: "short",
|
||||||
|
}).format(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Noon UTC anchor: date-only arithmetic immune to DST edges. */
|
||||||
|
function atNoon(date: string): Date {
|
||||||
|
return new Date(`${date}T12:00:00Z`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function addDays(date: string, days: number): string {
|
||||||
|
return new Date(atNoon(date).getTime() + days * 86_400_000).toISOString().slice(0, 10);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysBetween(from: string, to: string): number {
|
||||||
|
return Math.round((atNoon(to).getTime() - atNoon(from).getTime()) / 86_400_000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 0 = Sunday … 6 = Saturday. */
|
||||||
|
export function weekdayOf(date: string): number {
|
||||||
|
return atNoon(date).getUTCDay();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1-based campaign day; may exceed CAMPAIGN_DAYS after the camp ends. */
|
||||||
|
export function campaignDay(date: string): number {
|
||||||
|
return daysBetween(CAMPAIGN_START, date) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** 1-based campaign week (Mon–Sun), aligned to CAMPAIGN_START. */
|
||||||
|
export function campaignWeek(date: string): number {
|
||||||
|
return Math.floor(daysBetween(CAMPAIGN_START, date) / 7) + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isoWeek(date: string): number {
|
||||||
|
const d = atNoon(date);
|
||||||
|
// ISO 8601: week containing the year's first Thursday is week 1.
|
||||||
|
d.setUTCDate(d.getUTCDate() + 4 - (d.getUTCDay() || 7));
|
||||||
|
const jan1 = new Date(Date.UTC(d.getUTCFullYear(), 0, 1));
|
||||||
|
return Math.ceil(((d.getTime() - jan1.getTime()) / 86_400_000 + 1) / 7);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function prettyDate(date: string): string {
|
||||||
|
return new Intl.DateTimeFormat("en-US", {
|
||||||
|
timeZone: "UTC",
|
||||||
|
weekday: "long",
|
||||||
|
month: "long",
|
||||||
|
day: "numeric",
|
||||||
|
}).format(atNoon(date));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the interval ladder ──────────────────────────────────────────
|
||||||
|
|
||||||
|
export const INTERVAL: Record<string, number> = { "+2": 2, "+5": 5, "+10": 10 };
|
||||||
|
const NEXT_STAGE: Record<string, Stage> = { new: "+2", "+2": "+5", "+5": "+10", "+10": "retired" };
|
||||||
|
|
||||||
|
/** Advance on a pass; returns false when the problem was already retired. */
|
||||||
|
export function pass(p: ProblemState, today: string): boolean {
|
||||||
|
const next = NEXT_STAGE[p.stage];
|
||||||
|
if (!next) return false;
|
||||||
|
p.stage = next;
|
||||||
|
p.solved_on = today;
|
||||||
|
if (next === "retired") {
|
||||||
|
delete p.next_review;
|
||||||
|
} else {
|
||||||
|
p.next_review = addDays(today, INTERVAL[next]!);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset on a failure: back to the +2 stage, due again in 2 days. */
|
||||||
|
export function fail(p: ProblemState, today: string): void {
|
||||||
|
p.stage = "+2";
|
||||||
|
p.next_review = addDays(today, 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reviews due today or earlier, oldest first — the overflow carry order. */
|
||||||
|
export function dueReviews(state: State, today: string): [string, ProblemState][] {
|
||||||
|
return Object.entries(state.problems)
|
||||||
|
.filter(
|
||||||
|
([, p]) =>
|
||||||
|
p.stage !== "retired" &&
|
||||||
|
p.next_review !== undefined &&
|
||||||
|
p.next_review <= today &&
|
||||||
|
(p.defer_until === undefined || p.defer_until <= today),
|
||||||
|
)
|
||||||
|
.sort(([a, pa], [b, pb]) =>
|
||||||
|
pa.next_review === pb.next_review
|
||||||
|
? Number(a) - Number(b)
|
||||||
|
: pa.next_review! < pb.next_review!
|
||||||
|
? -1
|
||||||
|
: 1,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── deterministic sampling ───────────────────────────────────────
|
||||||
|
|
||||||
|
/** FNV-1a → mulberry32: a seeded PRNG so re-runs pick identical problems. */
|
||||||
|
export function rng(seed: string): () => number {
|
||||||
|
let h = 0x811c9dc5;
|
||||||
|
for (let i = 0; i < seed.length; i++) {
|
||||||
|
h ^= seed.charCodeAt(i);
|
||||||
|
h = Math.imul(h, 0x01000193);
|
||||||
|
}
|
||||||
|
return () => {
|
||||||
|
h = Math.imul(h ^ (h >>> 15), h | 1);
|
||||||
|
h ^= h + Math.imul(h ^ (h >>> 7), h | 61);
|
||||||
|
return ((h ^ (h >>> 14)) >>> 0) / 4294967296;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Take up to n elements, Fisher–Yates order driven by the seeded PRNG. */
|
||||||
|
export function sample<T>(pool: T[], n: number, random: () => number): T[] {
|
||||||
|
const copy = [...pool];
|
||||||
|
for (let i = copy.length - 1; i > 0; i--) {
|
||||||
|
const j = Math.floor(random() * (i + 1));
|
||||||
|
[copy[i], copy[j]] = [copy[j]!, copy[i]!];
|
||||||
|
}
|
||||||
|
return copy.slice(0, n);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── the live catalog (topics + problem sub-issues) ───────────────
|
||||||
|
|
||||||
|
export interface CatalogProblem {
|
||||||
|
lc: number;
|
||||||
|
issue: number;
|
||||||
|
name: string;
|
||||||
|
difficulty: "Easy" | "Medium" | "Hard";
|
||||||
|
set: "core" | "optional" | "deferred";
|
||||||
|
topic: number;
|
||||||
|
open: boolean;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CatalogTopic {
|
||||||
|
issue: number;
|
||||||
|
/** e.g. "Topic 03 — Two Pointers". */
|
||||||
|
title: string;
|
||||||
|
/** Bare name, e.g. "Two Pointers". */
|
||||||
|
name: string;
|
||||||
|
milestone: number | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Catalog {
|
||||||
|
topics: Map<number, CatalogTopic>;
|
||||||
|
problems: Map<number, CatalogProblem>;
|
||||||
|
byIssue: Map<number, CatalogProblem>;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TITLE_RE = /^LC (\d+) · (.+) · (Easy|Medium|Hard) · (core|optional|deferred)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Walk every `topic` issue and its sub-issues into one lookup structure.
|
||||||
|
* The sub-issue linkage is authoritative: every problem hangs off exactly
|
||||||
|
* one topic (verified against the full issue inventory).
|
||||||
|
*/
|
||||||
|
export async function fetchCatalog(gh: GitHub): Promise<Catalog> {
|
||||||
|
const topics = new Map<number, CatalogTopic>();
|
||||||
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues?labels=topic&state=all`)) {
|
||||||
|
const t = raw as {
|
||||||
|
number: number;
|
||||||
|
title: string;
|
||||||
|
pull_request?: unknown;
|
||||||
|
milestone?: { number: number } | null;
|
||||||
|
};
|
||||||
|
if (t.pull_request) continue;
|
||||||
|
const name = t.title.replace(/^Topic \d+ — /, "");
|
||||||
|
topics.set(t.number, {
|
||||||
|
issue: t.number,
|
||||||
|
title: t.title,
|
||||||
|
name,
|
||||||
|
milestone: t.milestone?.number,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const problems = new Map<number, CatalogProblem>();
|
||||||
|
const byIssue = new Map<number, CatalogProblem>();
|
||||||
|
for (const topic of topics.keys()) {
|
||||||
|
for await (const raw of gh.list(`/repos/${gh.repo}/issues/${topic}/sub_issues`)) {
|
||||||
|
const s = raw as { number: number; title: string; state: string; body?: string };
|
||||||
|
const m = s.title.match(TITLE_RE);
|
||||||
|
if (!m) continue; // non-curriculum sub-issue
|
||||||
|
const url = (s.body ?? "").match(/https:\/\/leetcode\.com\/problems\/[a-z0-9-]+\/?/)?.[0];
|
||||||
|
const p: CatalogProblem = {
|
||||||
|
lc: Number(m[1]),
|
||||||
|
issue: s.number,
|
||||||
|
name: m[2]!,
|
||||||
|
difficulty: m[3] as CatalogProblem["difficulty"],
|
||||||
|
set: m[4] as CatalogProblem["set"],
|
||||||
|
topic,
|
||||||
|
open: s.state === "open",
|
||||||
|
url: url ?? "",
|
||||||
|
};
|
||||||
|
problems.set(p.lc, p);
|
||||||
|
byIssue.set(p.issue, p);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { topics, problems, byIssue };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── shared constants ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const TODAY_TITLE = "📋 Today";
|
||||||
Reference in New Issue
Block a user