@sovovs/bycli 2.1.28 → 2.1.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli-manifest.json +296 -0
- package/clis/github/repo.js +47 -0
- package/clis/github/search.js +281 -0
- package/clis/github/utils.js +150 -0
- package/clis/juejin/search.js +397 -0
- package/clis/weixin/create-draft.js +63 -18
- package/dist/src/browser/page.d.ts +1 -0
- package/dist/src/browser/page.js +6 -3
- package/dist/src/output.js +26 -5
- package/dist/src/types.d.ts +1 -0
- package/package.json +1 -1
package/cli-manifest.json
CHANGED
|
@@ -11320,6 +11320,221 @@
|
|
|
11320
11320
|
"modulePath": "gitee/user.js",
|
|
11321
11321
|
"sourceFile": "gitee/user.js"
|
|
11322
11322
|
},
|
|
11323
|
+
{
|
|
11324
|
+
"site": "github",
|
|
11325
|
+
"name": "repo",
|
|
11326
|
+
"description": "Single-repository metadata including the true watch count",
|
|
11327
|
+
"access": "read",
|
|
11328
|
+
"domain": "api.github.com",
|
|
11329
|
+
"strategy": "public",
|
|
11330
|
+
"browser": false,
|
|
11331
|
+
"args": [
|
|
11332
|
+
{
|
|
11333
|
+
"name": "repo",
|
|
11334
|
+
"type": "str",
|
|
11335
|
+
"required": true,
|
|
11336
|
+
"positional": true,
|
|
11337
|
+
"help": "Repository as \"owner/repo\" (the full_name column from search) or its github.com URL"
|
|
11338
|
+
}
|
|
11339
|
+
],
|
|
11340
|
+
"columns": [
|
|
11341
|
+
"full_name",
|
|
11342
|
+
"stars",
|
|
11343
|
+
"forks",
|
|
11344
|
+
"watchers",
|
|
11345
|
+
"open_issues",
|
|
11346
|
+
"language",
|
|
11347
|
+
"description",
|
|
11348
|
+
"license",
|
|
11349
|
+
"topics",
|
|
11350
|
+
"homepage",
|
|
11351
|
+
"default_branch",
|
|
11352
|
+
"archived",
|
|
11353
|
+
"is_fork",
|
|
11354
|
+
"size_kb",
|
|
11355
|
+
"created",
|
|
11356
|
+
"pushed",
|
|
11357
|
+
"url"
|
|
11358
|
+
],
|
|
11359
|
+
"type": "js",
|
|
11360
|
+
"modulePath": "github/repo.js",
|
|
11361
|
+
"sourceFile": "github/repo.js"
|
|
11362
|
+
},
|
|
11363
|
+
{
|
|
11364
|
+
"site": "github",
|
|
11365
|
+
"name": "search",
|
|
11366
|
+
"description": "Search GitHub repositories by stars, forks, watchers, language, topic and more",
|
|
11367
|
+
"access": "read",
|
|
11368
|
+
"domain": "api.github.com",
|
|
11369
|
+
"strategy": "public",
|
|
11370
|
+
"browser": false,
|
|
11371
|
+
"args": [
|
|
11372
|
+
{
|
|
11373
|
+
"name": "query",
|
|
11374
|
+
"type": "str",
|
|
11375
|
+
"required": false,
|
|
11376
|
+
"positional": true,
|
|
11377
|
+
"help": "Free-text keyword; optional when at least one filter flag is given"
|
|
11378
|
+
},
|
|
11379
|
+
{
|
|
11380
|
+
"name": "stars",
|
|
11381
|
+
"type": "str",
|
|
11382
|
+
"required": false,
|
|
11383
|
+
"help": "Star count filter: \"5000\" (>=), \">10000\", \"<500\", \"1000..5000\""
|
|
11384
|
+
},
|
|
11385
|
+
{
|
|
11386
|
+
"name": "forks",
|
|
11387
|
+
"type": "str",
|
|
11388
|
+
"required": false,
|
|
11389
|
+
"help": "Fork count filter, same syntax as --stars"
|
|
11390
|
+
},
|
|
11391
|
+
{
|
|
11392
|
+
"name": "watchers",
|
|
11393
|
+
"type": "str",
|
|
11394
|
+
"required": false,
|
|
11395
|
+
"help": "Watch count filter, same syntax as --stars (client-side; costs 1 API call per scanned repo)"
|
|
11396
|
+
},
|
|
11397
|
+
{
|
|
11398
|
+
"name": "language",
|
|
11399
|
+
"type": "str",
|
|
11400
|
+
"required": false,
|
|
11401
|
+
"help": "Primary language, comma-separated for multiple (e.g. \"rust,go\")"
|
|
11402
|
+
},
|
|
11403
|
+
{
|
|
11404
|
+
"name": "topic",
|
|
11405
|
+
"type": "str",
|
|
11406
|
+
"required": false,
|
|
11407
|
+
"help": "Repository topic, comma-separated for multiple (e.g. \"cli,devtools\")"
|
|
11408
|
+
},
|
|
11409
|
+
{
|
|
11410
|
+
"name": "license",
|
|
11411
|
+
"type": "str",
|
|
11412
|
+
"required": false,
|
|
11413
|
+
"help": "License keyword (e.g. \"mit\", \"apache-2.0\")"
|
|
11414
|
+
},
|
|
11415
|
+
{
|
|
11416
|
+
"name": "size",
|
|
11417
|
+
"type": "str",
|
|
11418
|
+
"required": false,
|
|
11419
|
+
"help": "Repo size in KB: \"1000\" (>=), \"<500\", \"100..1000\""
|
|
11420
|
+
},
|
|
11421
|
+
{
|
|
11422
|
+
"name": "pushed",
|
|
11423
|
+
"type": "str",
|
|
11424
|
+
"required": false,
|
|
11425
|
+
"help": "Last push date: \"2026-01-01\" (>=), \">2026-01-01\", \"2025-01-01..2026-01-01\""
|
|
11426
|
+
},
|
|
11427
|
+
{
|
|
11428
|
+
"name": "created",
|
|
11429
|
+
"type": "str",
|
|
11430
|
+
"required": false,
|
|
11431
|
+
"help": "Creation date, same syntax as --pushed"
|
|
11432
|
+
},
|
|
11433
|
+
{
|
|
11434
|
+
"name": "issues",
|
|
11435
|
+
"type": "str",
|
|
11436
|
+
"required": false,
|
|
11437
|
+
"help": "Good-first-issue count filter, same syntax as --stars"
|
|
11438
|
+
},
|
|
11439
|
+
{
|
|
11440
|
+
"name": "owner",
|
|
11441
|
+
"type": "str",
|
|
11442
|
+
"required": false,
|
|
11443
|
+
"help": "Restrict to owners/orgs, comma-separated (e.g. \"facebook,vercel\")"
|
|
11444
|
+
},
|
|
11445
|
+
{
|
|
11446
|
+
"name": "in",
|
|
11447
|
+
"type": "str",
|
|
11448
|
+
"required": false,
|
|
11449
|
+
"help": "Restrict free-text match to: name, description, readme, topics (comma-separated)"
|
|
11450
|
+
},
|
|
11451
|
+
{
|
|
11452
|
+
"name": "include-forks",
|
|
11453
|
+
"type": "bool",
|
|
11454
|
+
"default": false,
|
|
11455
|
+
"required": false,
|
|
11456
|
+
"help": "Include forked repos (GitHub excludes them by default)"
|
|
11457
|
+
},
|
|
11458
|
+
{
|
|
11459
|
+
"name": "include-archived",
|
|
11460
|
+
"type": "bool",
|
|
11461
|
+
"default": false,
|
|
11462
|
+
"required": false,
|
|
11463
|
+
"help": "Include archived repos"
|
|
11464
|
+
},
|
|
11465
|
+
{
|
|
11466
|
+
"name": "sort",
|
|
11467
|
+
"type": "str",
|
|
11468
|
+
"default": "best-match",
|
|
11469
|
+
"required": false,
|
|
11470
|
+
"help": "Ranking dimension; \"watchers\" sorts client-side and enriches every scanned row",
|
|
11471
|
+
"choices": [
|
|
11472
|
+
"best-match",
|
|
11473
|
+
"stars",
|
|
11474
|
+
"forks",
|
|
11475
|
+
"watchers",
|
|
11476
|
+
"updated",
|
|
11477
|
+
"help-wanted-issues"
|
|
11478
|
+
]
|
|
11479
|
+
},
|
|
11480
|
+
{
|
|
11481
|
+
"name": "order",
|
|
11482
|
+
"type": "str",
|
|
11483
|
+
"default": "desc",
|
|
11484
|
+
"required": false,
|
|
11485
|
+
"help": "Sort direction",
|
|
11486
|
+
"choices": [
|
|
11487
|
+
"desc",
|
|
11488
|
+
"asc"
|
|
11489
|
+
]
|
|
11490
|
+
},
|
|
11491
|
+
{
|
|
11492
|
+
"name": "limit",
|
|
11493
|
+
"type": "int",
|
|
11494
|
+
"default": 20,
|
|
11495
|
+
"required": false,
|
|
11496
|
+
"help": "Max results (1-100)"
|
|
11497
|
+
},
|
|
11498
|
+
{
|
|
11499
|
+
"name": "page",
|
|
11500
|
+
"type": "int",
|
|
11501
|
+
"default": 1,
|
|
11502
|
+
"required": false,
|
|
11503
|
+
"help": "Result page; GitHub never serves past result 1000"
|
|
11504
|
+
},
|
|
11505
|
+
{
|
|
11506
|
+
"name": "scan",
|
|
11507
|
+
"type": "int",
|
|
11508
|
+
"default": 30,
|
|
11509
|
+
"required": false,
|
|
11510
|
+
"help": "Candidate pool size when filtering/sorting by watchers (1-100, one API call each)"
|
|
11511
|
+
},
|
|
11512
|
+
{
|
|
11513
|
+
"name": "with-watchers",
|
|
11514
|
+
"type": "bool",
|
|
11515
|
+
"default": false,
|
|
11516
|
+
"required": false,
|
|
11517
|
+
"help": "Add the real watch count column without filtering on it"
|
|
11518
|
+
}
|
|
11519
|
+
],
|
|
11520
|
+
"columns": [
|
|
11521
|
+
"rank",
|
|
11522
|
+
"full_name",
|
|
11523
|
+
"stars",
|
|
11524
|
+
"forks",
|
|
11525
|
+
"watchers",
|
|
11526
|
+
"language",
|
|
11527
|
+
"description",
|
|
11528
|
+
"license",
|
|
11529
|
+
"topics",
|
|
11530
|
+
"open_issues",
|
|
11531
|
+
"pushed",
|
|
11532
|
+
"url"
|
|
11533
|
+
],
|
|
11534
|
+
"type": "js",
|
|
11535
|
+
"modulePath": "github/search.js",
|
|
11536
|
+
"sourceFile": "github/search.js"
|
|
11537
|
+
},
|
|
11323
11538
|
{
|
|
11324
11539
|
"site": "google",
|
|
11325
11540
|
"name": "news",
|
|
@@ -14863,6 +15078,87 @@
|
|
|
14863
15078
|
"modulePath": "jira/search.js",
|
|
14864
15079
|
"sourceFile": "jira/search.js"
|
|
14865
15080
|
},
|
|
15081
|
+
{
|
|
15082
|
+
"site": "juejin",
|
|
15083
|
+
"name": "search",
|
|
15084
|
+
"description": "掘金搜索,支持综合/文章/课程/标签/用户维度与综合/最新/最热排序",
|
|
15085
|
+
"access": "read",
|
|
15086
|
+
"domain": "api.juejin.cn",
|
|
15087
|
+
"strategy": "public",
|
|
15088
|
+
"browser": false,
|
|
15089
|
+
"args": [
|
|
15090
|
+
{
|
|
15091
|
+
"name": "query",
|
|
15092
|
+
"type": "str",
|
|
15093
|
+
"required": true,
|
|
15094
|
+
"positional": true,
|
|
15095
|
+
"help": "搜索关键词"
|
|
15096
|
+
},
|
|
15097
|
+
{
|
|
15098
|
+
"name": "type",
|
|
15099
|
+
"type": "str",
|
|
15100
|
+
"default": "all",
|
|
15101
|
+
"required": false,
|
|
15102
|
+
"help": "结果类型:all(综合) / article(文章) / course(课程) / tag(标签) / user(用户)",
|
|
15103
|
+
"choices": [
|
|
15104
|
+
"all",
|
|
15105
|
+
"article",
|
|
15106
|
+
"course",
|
|
15107
|
+
"tag",
|
|
15108
|
+
"user"
|
|
15109
|
+
]
|
|
15110
|
+
},
|
|
15111
|
+
{
|
|
15112
|
+
"name": "sort",
|
|
15113
|
+
"type": "str",
|
|
15114
|
+
"default": "relevance",
|
|
15115
|
+
"required": false,
|
|
15116
|
+
"help": "排序:relevance(综合) / newest(最新优先) / hottest(最热优先)",
|
|
15117
|
+
"choices": [
|
|
15118
|
+
"relevance",
|
|
15119
|
+
"newest",
|
|
15120
|
+
"hottest"
|
|
15121
|
+
]
|
|
15122
|
+
},
|
|
15123
|
+
{
|
|
15124
|
+
"name": "period",
|
|
15125
|
+
"type": "str",
|
|
15126
|
+
"default": "all",
|
|
15127
|
+
"required": false,
|
|
15128
|
+
"help": "时间范围:all(不限) / day(最近一天) / week(最近一周) / month3(最近三月)",
|
|
15129
|
+
"choices": [
|
|
15130
|
+
"all",
|
|
15131
|
+
"day",
|
|
15132
|
+
"week",
|
|
15133
|
+
"month3"
|
|
15134
|
+
]
|
|
15135
|
+
},
|
|
15136
|
+
{
|
|
15137
|
+
"name": "limit",
|
|
15138
|
+
"type": "int",
|
|
15139
|
+
"default": 20,
|
|
15140
|
+
"required": false,
|
|
15141
|
+
"help": "返回条数 (max 200)"
|
|
15142
|
+
}
|
|
15143
|
+
],
|
|
15144
|
+
"columns": [
|
|
15145
|
+
"rank",
|
|
15146
|
+
"kind",
|
|
15147
|
+
"id",
|
|
15148
|
+
"title",
|
|
15149
|
+
"author",
|
|
15150
|
+
"views",
|
|
15151
|
+
"likes",
|
|
15152
|
+
"comments",
|
|
15153
|
+
"hot_index",
|
|
15154
|
+
"published_at",
|
|
15155
|
+
"url",
|
|
15156
|
+
"extra"
|
|
15157
|
+
],
|
|
15158
|
+
"type": "js",
|
|
15159
|
+
"modulePath": "juejin/search.js",
|
|
15160
|
+
"sourceFile": "juejin/search.js"
|
|
15161
|
+
},
|
|
14866
15162
|
{
|
|
14867
15163
|
"site": "ke",
|
|
14868
15164
|
"name": "chengjiao",
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
// github repo — single-repository metadata from `/repos/{owner}/{repo}`.
|
|
2
|
+
//
|
|
3
|
+
// Pairs with `github search`: the `full_name` column from a search row goes
|
|
4
|
+
// straight into this command's positional argument. This is also the only
|
|
5
|
+
// endpoint that reports a true watch count (`subscribers_count`); the search
|
|
6
|
+
// index does not carry it.
|
|
7
|
+
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
8
|
+
import { GITHUB_API, githubFetch, requireFullName } from './utils.js';
|
|
9
|
+
|
|
10
|
+
cli({
|
|
11
|
+
site: 'github',
|
|
12
|
+
name: 'repo',
|
|
13
|
+
access: 'read',
|
|
14
|
+
description: 'Single-repository metadata including the true watch count',
|
|
15
|
+
domain: 'api.github.com',
|
|
16
|
+
strategy: Strategy.PUBLIC,
|
|
17
|
+
browser: false,
|
|
18
|
+
args: [
|
|
19
|
+
{ name: 'repo', positional: true, required: true, help: 'Repository as "owner/repo" (the full_name column from search) or its github.com URL' },
|
|
20
|
+
],
|
|
21
|
+
columns: ['full_name', 'stars', 'forks', 'watchers', 'open_issues', 'language', 'description', 'license', 'topics', 'homepage', 'default_branch', 'archived', 'is_fork', 'size_kb', 'created', 'pushed', 'url'],
|
|
22
|
+
func: async (args) => {
|
|
23
|
+
const fullName = requireFullName(args.repo);
|
|
24
|
+
const r = await githubFetch(`${GITHUB_API}/repos/${fullName}`, `github repo ${fullName}`);
|
|
25
|
+
return [{
|
|
26
|
+
full_name: String(r?.full_name ?? fullName),
|
|
27
|
+
stars: r?.stargazers_count != null ? Number(r.stargazers_count) : null,
|
|
28
|
+
forks: r?.forks_count != null ? Number(r.forks_count) : null,
|
|
29
|
+
// `subscribers_count` is the watch count. `watchers_count` on this
|
|
30
|
+
// payload is a legacy alias for the star count — do not use it.
|
|
31
|
+
watchers: r?.subscribers_count != null ? Number(r.subscribers_count) : null,
|
|
32
|
+
open_issues: r?.open_issues_count != null ? Number(r.open_issues_count) : null,
|
|
33
|
+
language: String(r?.language ?? ''),
|
|
34
|
+
description: String(r?.description ?? '').trim(),
|
|
35
|
+
license: String(r?.license?.spdx_id ?? ''),
|
|
36
|
+
topics: Array.isArray(r?.topics) ? r.topics.join(', ') : '',
|
|
37
|
+
homepage: String(r?.homepage ?? ''),
|
|
38
|
+
default_branch: String(r?.default_branch ?? ''),
|
|
39
|
+
archived: Boolean(r?.archived),
|
|
40
|
+
is_fork: Boolean(r?.fork),
|
|
41
|
+
size_kb: r?.size != null ? Number(r.size) : null,
|
|
42
|
+
created: String(r?.created_at ?? '').slice(0, 10),
|
|
43
|
+
pushed: String(r?.pushed_at ?? '').slice(0, 10),
|
|
44
|
+
url: String(r?.html_url ?? `https://github.com/${fullName}`),
|
|
45
|
+
}];
|
|
46
|
+
},
|
|
47
|
+
});
|
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
// github search — multi-dimension repository search over the public GitHub
|
|
2
|
+
// REST API (`/search/repositories`).
|
|
3
|
+
//
|
|
4
|
+
// Dimensions map to GitHub search qualifiers server-side (stars, forks,
|
|
5
|
+
// language, topic, license, size, created, pushed, owner) so filtering happens
|
|
6
|
+
// before results are paged.
|
|
7
|
+
//
|
|
8
|
+
// The watch dimension is the exception. GitHub's search index has NO watcher
|
|
9
|
+
// qualifier — `watchers:`/`followers:` are aliases for the star count — and
|
|
10
|
+
// search rows report `watchers_count` as a mirror of `stargazers_count`. The
|
|
11
|
+
// real watch count only exists as `subscribers_count` on the repo endpoint, so
|
|
12
|
+
// `--watchers` / `--sort watchers` scan a candidate pool and enrich each row
|
|
13
|
+
// with one extra API call apiece. See `--scan`.
|
|
14
|
+
import { cli, Strategy } from '@sovovs/bycli/registry';
|
|
15
|
+
import { ArgumentError, EmptyResultError } from '@sovovs/bycli/errors';
|
|
16
|
+
import {
|
|
17
|
+
GITHUB_API,
|
|
18
|
+
buildDateQualifier,
|
|
19
|
+
buildRangeQualifier,
|
|
20
|
+
githubFetch,
|
|
21
|
+
requireBoundedInt,
|
|
22
|
+
} from './utils.js';
|
|
23
|
+
|
|
24
|
+
const MAX_LIMIT = 100; // GitHub caps per_page at 100 for search
|
|
25
|
+
const MAX_SCAN = 100;
|
|
26
|
+
const ENRICH_CONCURRENCY = 5;
|
|
27
|
+
// GitHub's search index never returns past result 1000, regardless of per_page.
|
|
28
|
+
const MAX_SEARCH_WINDOW = 1000;
|
|
29
|
+
|
|
30
|
+
/** Server-side sorts GitHub understands; `best-match` means "omit the param". */
|
|
31
|
+
const SERVER_SORTS = new Set(['stars', 'forks', 'updated', 'help-wanted-issues']);
|
|
32
|
+
|
|
33
|
+
/** Split a comma-separated arg into trimmed, non-empty terms. */
|
|
34
|
+
function splitTerms(value) {
|
|
35
|
+
return String(value ?? '')
|
|
36
|
+
.split(',')
|
|
37
|
+
.map((s) => s.trim())
|
|
38
|
+
.filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Quote a qualifier value when it contains whitespace, so
|
|
43
|
+
* `--license "MIT License"` doesn't split into two qualifiers.
|
|
44
|
+
*/
|
|
45
|
+
function quoteIfNeeded(value) {
|
|
46
|
+
return /\s/.test(value) ? `"${value}"` : value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Turn a `--watchers` value into a local predicate.
|
|
51
|
+
*
|
|
52
|
+
* Reuses `buildRangeQualifier` for validation so the accepted syntax can't
|
|
53
|
+
* drift from the server-side range flags.
|
|
54
|
+
*/
|
|
55
|
+
export function compileWatchersPredicate(value) {
|
|
56
|
+
const qualifier = buildRangeQualifier('watchers', value);
|
|
57
|
+
const expr = qualifier.slice('watchers:'.length);
|
|
58
|
+
let m;
|
|
59
|
+
if ((m = expr.match(/^>=(\d+)$/))) return (n) => n >= Number(m[1]);
|
|
60
|
+
if ((m = expr.match(/^>(\d+)$/))) return (n) => n > Number(m[1]);
|
|
61
|
+
if ((m = expr.match(/^<=(\d+)$/))) return (n) => n <= Number(m[1]);
|
|
62
|
+
if ((m = expr.match(/^<(\d+)$/))) return (n) => n < Number(m[1]);
|
|
63
|
+
if ((m = expr.match(/^(\d+)\.\.(\d+)$/))) return (n) => n >= Number(m[1]) && n <= Number(m[2]);
|
|
64
|
+
if ((m = expr.match(/^(\d+)\.\.\*$/))) return (n) => n >= Number(m[1]);
|
|
65
|
+
if ((m = expr.match(/^\*\.\.(\d+)$/))) return (n) => n <= Number(m[1]);
|
|
66
|
+
// buildRangeQualifier already rejected anything else.
|
|
67
|
+
throw new ArgumentError(`github --watchers value "${value}" is not a valid numeric filter`);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Assemble the `q=` search expression from free text plus every dimension. */
|
|
71
|
+
export function buildQuery(args = {}) {
|
|
72
|
+
const parts = [];
|
|
73
|
+
const freeText = String(args.query ?? '').trim();
|
|
74
|
+
if (freeText) parts.push(freeText);
|
|
75
|
+
|
|
76
|
+
for (const [flag, field] of [['stars', 'stars'], ['forks', 'forks'], ['issues', 'good-first-issues'], ['size', 'size']]) {
|
|
77
|
+
if (args[flag] != null && String(args[flag]).trim()) {
|
|
78
|
+
parts.push(buildRangeQualifier(field, args[flag]));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
for (const [flag, field] of [['pushed', 'pushed'], ['created', 'created']]) {
|
|
82
|
+
if (args[flag] != null && String(args[flag]).trim()) {
|
|
83
|
+
parts.push(buildDateQualifier(field, args[flag]));
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const [flag, field] of [['language', 'language'], ['topic', 'topic'], ['license', 'license']]) {
|
|
87
|
+
for (const term of splitTerms(args[flag])) {
|
|
88
|
+
parts.push(`${field}:${quoteIfNeeded(term)}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
for (const term of splitTerms(args.owner)) {
|
|
92
|
+
parts.push(`user:${term}`);
|
|
93
|
+
}
|
|
94
|
+
if (args.in) {
|
|
95
|
+
const fields = splitTerms(args.in);
|
|
96
|
+
const allowed = new Set(['name', 'description', 'readme', 'topics']);
|
|
97
|
+
for (const f of fields) {
|
|
98
|
+
if (!allowed.has(f)) {
|
|
99
|
+
throw new ArgumentError(
|
|
100
|
+
`github --in value "${f}" is not searchable`,
|
|
101
|
+
'GitHub restricts free-text scope to: name, description, readme, topics.',
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
if (fields.length) parts.push(`in:${fields.join(',')}`);
|
|
106
|
+
}
|
|
107
|
+
// GitHub excludes forks from search by default; archived repos are included.
|
|
108
|
+
if (args['include-forks']) parts.push('fork:true');
|
|
109
|
+
if (!args['include-archived']) parts.push('archived:false');
|
|
110
|
+
|
|
111
|
+
// `archived:false` alone is a valid GitHub query but would silently return
|
|
112
|
+
// "every non-archived repo", which is never what the caller meant.
|
|
113
|
+
const meaningful = parts.filter((p) => p !== 'archived:false' && p !== 'fork:true');
|
|
114
|
+
if (!meaningful.length) {
|
|
115
|
+
throw new ArgumentError(
|
|
116
|
+
'github search needs a query or at least one filter',
|
|
117
|
+
'Pass a keyword, or a dimension such as --stars ">10000" --language rust --topic cli.',
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
return parts.join(' ');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Run `mapper` over `items` with a bounded number of in-flight requests. */
|
|
124
|
+
async function mapWithConcurrency(items, concurrency, mapper) {
|
|
125
|
+
const out = new Array(items.length);
|
|
126
|
+
let cursor = 0;
|
|
127
|
+
const workers = Array.from({ length: Math.min(concurrency, items.length) }, async () => {
|
|
128
|
+
while (cursor < items.length) {
|
|
129
|
+
const i = cursor++;
|
|
130
|
+
out[i] = await mapper(items[i], i);
|
|
131
|
+
}
|
|
132
|
+
});
|
|
133
|
+
await Promise.all(workers);
|
|
134
|
+
return out;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Fetch the real watch count (`subscribers_count`) for each row.
|
|
139
|
+
*
|
|
140
|
+
* One core-API call per repo. Failures degrade to `null` rather than killing
|
|
141
|
+
* the whole search — a single deleted/renamed repo shouldn't lose 99 good rows.
|
|
142
|
+
*/
|
|
143
|
+
async function enrichWatchers(rows) {
|
|
144
|
+
return mapWithConcurrency(rows, ENRICH_CONCURRENCY, async (row) => {
|
|
145
|
+
try {
|
|
146
|
+
const detail = await githubFetch(
|
|
147
|
+
`${GITHUB_API}/repos/${row.full_name}`,
|
|
148
|
+
'github search (watch count)',
|
|
149
|
+
{ allow404: false },
|
|
150
|
+
);
|
|
151
|
+
return { ...row, watchers: detail?.subscribers_count != null ? Number(detail.subscribers_count) : null };
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return { ...row, watchers: null };
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** Shape one raw search item into an output row. */
|
|
160
|
+
function toRow(item) {
|
|
161
|
+
return {
|
|
162
|
+
rank: 0, // assigned after filtering/sorting
|
|
163
|
+
full_name: String(item?.full_name ?? ''),
|
|
164
|
+
stars: item?.stargazers_count != null ? Number(item.stargazers_count) : null,
|
|
165
|
+
forks: item?.forks_count != null ? Number(item.forks_count) : null,
|
|
166
|
+
// Deliberately null: the search payload's `watchers_count` is a copy of
|
|
167
|
+
// the star count, not a watch count. Only enrichment fills this in.
|
|
168
|
+
watchers: null,
|
|
169
|
+
language: String(item?.language ?? ''),
|
|
170
|
+
description: String(item?.description ?? '').trim(),
|
|
171
|
+
license: String(item?.license?.spdx_id ?? ''),
|
|
172
|
+
topics: Array.isArray(item?.topics) ? item.topics.join(', ') : '',
|
|
173
|
+
open_issues: item?.open_issues_count != null ? Number(item.open_issues_count) : null,
|
|
174
|
+
pushed: String(item?.pushed_at ?? '').slice(0, 10),
|
|
175
|
+
url: String(item?.html_url ?? (item?.full_name ? `https://github.com/${item.full_name}` : '')),
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
cli({
|
|
180
|
+
site: 'github',
|
|
181
|
+
name: 'search',
|
|
182
|
+
access: 'read',
|
|
183
|
+
description: 'Search GitHub repositories by stars, forks, watchers, language, topic and more',
|
|
184
|
+
domain: 'api.github.com',
|
|
185
|
+
strategy: Strategy.PUBLIC,
|
|
186
|
+
browser: false,
|
|
187
|
+
args: [
|
|
188
|
+
{ name: 'query', positional: true, required: false, help: 'Free-text keyword; optional when at least one filter flag is given' },
|
|
189
|
+
{ name: 'stars', help: 'Star count filter: "5000" (>=), ">10000", "<500", "1000..5000"' },
|
|
190
|
+
{ name: 'forks', help: 'Fork count filter, same syntax as --stars' },
|
|
191
|
+
{ name: 'watchers', help: 'Watch count filter, same syntax as --stars (client-side; costs 1 API call per scanned repo)' },
|
|
192
|
+
{ name: 'language', help: 'Primary language, comma-separated for multiple (e.g. "rust,go")' },
|
|
193
|
+
{ name: 'topic', help: 'Repository topic, comma-separated for multiple (e.g. "cli,devtools")' },
|
|
194
|
+
{ name: 'license', help: 'License keyword (e.g. "mit", "apache-2.0")' },
|
|
195
|
+
{ name: 'size', help: 'Repo size in KB: "1000" (>=), "<500", "100..1000"' },
|
|
196
|
+
{ name: 'pushed', help: 'Last push date: "2026-01-01" (>=), ">2026-01-01", "2025-01-01..2026-01-01"' },
|
|
197
|
+
{ name: 'created', help: 'Creation date, same syntax as --pushed' },
|
|
198
|
+
{ name: 'issues', help: 'Good-first-issue count filter, same syntax as --stars' },
|
|
199
|
+
{ name: 'owner', help: 'Restrict to owners/orgs, comma-separated (e.g. "facebook,vercel")' },
|
|
200
|
+
{ name: 'in', help: 'Restrict free-text match to: name, description, readme, topics (comma-separated)' },
|
|
201
|
+
{ name: 'include-forks', type: 'bool', default: false, help: 'Include forked repos (GitHub excludes them by default)' },
|
|
202
|
+
{ name: 'include-archived', type: 'bool', default: false, help: 'Include archived repos' },
|
|
203
|
+
{
|
|
204
|
+
name: 'sort',
|
|
205
|
+
default: 'best-match',
|
|
206
|
+
choices: ['best-match', 'stars', 'forks', 'watchers', 'updated', 'help-wanted-issues'],
|
|
207
|
+
help: 'Ranking dimension; "watchers" sorts client-side and enriches every scanned row',
|
|
208
|
+
},
|
|
209
|
+
{ name: 'order', default: 'desc', choices: ['desc', 'asc'], help: 'Sort direction' },
|
|
210
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Max results (1-100)' },
|
|
211
|
+
{ name: 'page', type: 'int', default: 1, help: 'Result page; GitHub never serves past result 1000' },
|
|
212
|
+
{ name: 'scan', type: 'int', default: 30, help: 'Candidate pool size when filtering/sorting by watchers (1-100, one API call each)' },
|
|
213
|
+
{ name: 'with-watchers', type: 'bool', default: false, help: 'Add the real watch count column without filtering on it' },
|
|
214
|
+
],
|
|
215
|
+
columns: ['rank', 'full_name', 'stars', 'forks', 'watchers', 'language', 'description', 'license', 'topics', 'open_issues', 'pushed', 'url'],
|
|
216
|
+
func: async (args) => {
|
|
217
|
+
const limit = requireBoundedInt(args.limit, 20, MAX_LIMIT);
|
|
218
|
+
const page = requireBoundedInt(args.page, 1, MAX_SEARCH_WINDOW);
|
|
219
|
+
const sort = String(args.sort ?? 'best-match');
|
|
220
|
+
const order = String(args.order ?? 'desc') === 'asc' ? 'asc' : 'desc';
|
|
221
|
+
const wantsWatchers = Boolean(args.watchers) || Boolean(args['with-watchers']) || sort === 'watchers';
|
|
222
|
+
const q = buildQuery(args);
|
|
223
|
+
|
|
224
|
+
// Watch-count work happens client-side, so pull a larger candidate pool
|
|
225
|
+
// to filter/sort down from. Everything else is filtered by GitHub.
|
|
226
|
+
const scan = wantsWatchers ? requireBoundedInt(args.scan, 30, MAX_SCAN, 'scan') : limit;
|
|
227
|
+
const perPage = wantsWatchers ? Math.max(scan, limit) : limit;
|
|
228
|
+
|
|
229
|
+
if (page * perPage > MAX_SEARCH_WINDOW) {
|
|
230
|
+
throw new ArgumentError(
|
|
231
|
+
`github search cannot reach page ${page} at ${perPage} results per page`,
|
|
232
|
+
`GitHub's search index stops at result ${MAX_SEARCH_WINDOW}; narrow the query with more filters instead.`,
|
|
233
|
+
);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const url = new URL(`${GITHUB_API}/search/repositories`);
|
|
237
|
+
url.searchParams.set('q', q);
|
|
238
|
+
url.searchParams.set('per_page', String(Math.min(perPage, MAX_LIMIT)));
|
|
239
|
+
url.searchParams.set('page', String(page));
|
|
240
|
+
// `best-match` is GitHub's default and is expressed by omitting `sort`.
|
|
241
|
+
if (SERVER_SORTS.has(sort)) {
|
|
242
|
+
url.searchParams.set('sort', sort);
|
|
243
|
+
url.searchParams.set('order', order);
|
|
244
|
+
}
|
|
245
|
+
else if (sort === 'watchers') {
|
|
246
|
+
// No server-side watch sort exists; rank the pool by stars so the
|
|
247
|
+
// scanned candidates are the plausible ones, then re-sort locally.
|
|
248
|
+
url.searchParams.set('sort', 'stars');
|
|
249
|
+
url.searchParams.set('order', 'desc');
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const body = await githubFetch(url.toString(), 'github search');
|
|
253
|
+
const items = Array.isArray(body?.items) ? body.items : [];
|
|
254
|
+
if (!items.length) {
|
|
255
|
+
throw new EmptyResultError('github search', `No GitHub repositories matched "${q}".`);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
let rows = items.map(toRow).filter((r) => r.full_name);
|
|
259
|
+
if (wantsWatchers) {
|
|
260
|
+
rows = await enrichWatchers(rows.slice(0, scan));
|
|
261
|
+
if (args.watchers != null && String(args.watchers).trim()) {
|
|
262
|
+
const pred = compileWatchersPredicate(args.watchers);
|
|
263
|
+
rows = rows.filter((r) => r.watchers != null && pred(r.watchers));
|
|
264
|
+
}
|
|
265
|
+
if (sort === 'watchers') {
|
|
266
|
+
rows.sort((a, b) => (order === 'asc'
|
|
267
|
+
? (a.watchers ?? Infinity) - (b.watchers ?? Infinity)
|
|
268
|
+
: (b.watchers ?? -1) - (a.watchers ?? -1)));
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
rows = rows.slice(0, limit);
|
|
273
|
+
if (!rows.length) {
|
|
274
|
+
throw new EmptyResultError(
|
|
275
|
+
'github search',
|
|
276
|
+
`No repositories in the top ${scan} results for "${q}" matched --watchers ${args.watchers}. Raise --scan or loosen the filter.`,
|
|
277
|
+
);
|
|
278
|
+
}
|
|
279
|
+
return rows.map((r, i) => ({ ...r, rank: i + 1 }));
|
|
280
|
+
},
|
|
281
|
+
});
|