oss-issue-scout 0.2.0__tar.gz
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.
- oss_issue_scout-0.2.0/LICENSE +21 -0
- oss_issue_scout-0.2.0/PKG-INFO +116 -0
- oss_issue_scout-0.2.0/README.en.md +84 -0
- oss_issue_scout-0.2.0/README.md +86 -0
- oss_issue_scout-0.2.0/oss_issue_scout/__init__.py +3 -0
- oss_issue_scout-0.2.0/oss_issue_scout/cli.py +63 -0
- oss_issue_scout-0.2.0/oss_issue_scout/github_api.py +255 -0
- oss_issue_scout-0.2.0/oss_issue_scout/output.py +92 -0
- oss_issue_scout-0.2.0/oss_issue_scout/scoring.py +96 -0
- oss_issue_scout-0.2.0/oss_issue_scout.egg-info/PKG-INFO +116 -0
- oss_issue_scout-0.2.0/oss_issue_scout.egg-info/SOURCES.txt +18 -0
- oss_issue_scout-0.2.0/oss_issue_scout.egg-info/dependency_links.txt +1 -0
- oss_issue_scout-0.2.0/oss_issue_scout.egg-info/entry_points.txt +2 -0
- oss_issue_scout-0.2.0/oss_issue_scout.egg-info/top_level.txt +1 -0
- oss_issue_scout-0.2.0/pyproject.toml +21 -0
- oss_issue_scout-0.2.0/setup.cfg +4 -0
- oss_issue_scout-0.2.0/tests/test_cli.py +66 -0
- oss_issue_scout-0.2.0/tests/test_github_api.py +197 -0
- oss_issue_scout-0.2.0/tests/test_output.py +46 -0
- oss_issue_scout-0.2.0/tests/test_scoring.py +107 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Ruifeng Xue
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: oss-issue-scout
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Summary: Find worthwhile open-source issues.
|
|
5
|
+
Author: Ruifeng Xue
|
|
6
|
+
License: MIT License
|
|
7
|
+
|
|
8
|
+
Copyright (c) 2026 Ruifeng Xue
|
|
9
|
+
|
|
10
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
11
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
12
|
+
in the Software without restriction, including without limitation the rights
|
|
13
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
14
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
15
|
+
furnished to do so, subject to the following conditions:
|
|
16
|
+
|
|
17
|
+
The above copyright notice and this permission notice shall be included in all
|
|
18
|
+
copies or substantial portions of the Software.
|
|
19
|
+
|
|
20
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
21
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
22
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
23
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
24
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
25
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
26
|
+
SOFTWARE.
|
|
27
|
+
|
|
28
|
+
Requires-Python: >=3.10
|
|
29
|
+
Description-Content-Type: text/markdown
|
|
30
|
+
License-File: LICENSE
|
|
31
|
+
Dynamic: license-file
|
|
32
|
+
|
|
33
|
+
# oss-issue-scout
|
|
34
|
+
|
|
35
|
+
Find worthwhile open-source issues
|
|
36
|
+
|
|
37
|
+
The current version calls the GitHub API, searches open issues, and applies a simple score based on repository activity, issue activity, comments, labels, and related signals.
|
|
38
|
+
It is currently aimed at junior to intermediate developers who want a faster way to find approachable issues.
|
|
39
|
+
|
|
40
|
+
## Features
|
|
41
|
+
|
|
42
|
+
- Search GitHub open issues
|
|
43
|
+
- Filter by language, label, stars, and update recency
|
|
44
|
+
- Skip issues that already have linked PRs
|
|
45
|
+
- Recommend only unassigned issues by default
|
|
46
|
+
- Skip repositories with fewer than 100 stars by default
|
|
47
|
+
- Render results as `table`, `markdown`, or `json`
|
|
48
|
+
- No third-party dependencies
|
|
49
|
+
|
|
50
|
+
## Usage
|
|
51
|
+
|
|
52
|
+
```powershell
|
|
53
|
+
python -m pip install -e .
|
|
54
|
+
oss-issue-scout search --language python --label "good first issue" --limit 5
|
|
55
|
+
```
|
|
56
|
+
|
|
57
|
+
## Options
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
--language Repository primary language, such as python
|
|
61
|
+
--stars-min Minimum repository stars; defaults to at least 100
|
|
62
|
+
--label Issue label, such as "good first issue"
|
|
63
|
+
--updated-days Issue updated within the last N days
|
|
64
|
+
--repo-updated-days Repository had issue activity within the last N days
|
|
65
|
+
--limit Number of results, default 10
|
|
66
|
+
--format Output format: table, markdown, json
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
Examples:
|
|
70
|
+
|
|
71
|
+
```powershell
|
|
72
|
+
oss-issue-scout search --language python
|
|
73
|
+
oss-issue-scout search --language python --label "help wanted" --stars-min 500 --limit 10
|
|
74
|
+
oss-issue-scout search --language python --format json
|
|
75
|
+
oss-issue-scout search --language "C++" --label "good first issue" --repo-updated-days 7
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Scoring
|
|
79
|
+
|
|
80
|
+
The current score is intentionally simple. It considers:
|
|
81
|
+
|
|
82
|
+
- Repository stars: moderately active repos get a boost; very large repos may be penalized
|
|
83
|
+
- Issue update recency: recently updated issues get a boost; stale issues are penalized
|
|
84
|
+
- Repository issue activity: recent issue activity gets a boost
|
|
85
|
+
- Beginner-friendly labels: `good first issue` / `help wanted` only add points when the repo has at least 3 open issues with those labels
|
|
86
|
+
- Comment count: low discussion volume gets a boost; long discussions are penalized
|
|
87
|
+
|
|
88
|
+
The search step filters out:
|
|
89
|
+
|
|
90
|
+
- Closed issues
|
|
91
|
+
- Archived repositories
|
|
92
|
+
- Issues with linked PRs
|
|
93
|
+
- Assigned issues
|
|
94
|
+
- Repositories with fewer than 100 stars
|
|
95
|
+
|
|
96
|
+
## Tests
|
|
97
|
+
|
|
98
|
+
```powershell
|
|
99
|
+
python -m unittest discover
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
Tests use mocked GitHub responses and do not call the real GitHub API.
|
|
103
|
+
|
|
104
|
+
## Next
|
|
105
|
+
|
|
106
|
+
This project is still small. If it helps you, please consider giving it a ⭐. Discussions will be opened after the project reaches 16+ ⭐.
|
|
107
|
+
|
|
108
|
+
If you have suggestions or run into problems, please open an issue.
|
|
109
|
+
|
|
110
|
+
Future versions will continue to improve recommendation quality and usability.
|
|
111
|
+
|
|
112
|
+
## Contributors
|
|
113
|
+
|
|
114
|
+
<a href="https://github.com/Yong-yuan-X/oss-issue-scout/graphs/contributors">
|
|
115
|
+
<img src="https://contrib.rocks/image?repo=Yong-yuan-X/oss-issue-scout" alt="Contributors" />
|
|
116
|
+
</a>
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# oss-issue-scout
|
|
2
|
+
|
|
3
|
+
Find worthwhile open-source issues
|
|
4
|
+
|
|
5
|
+
The current version calls the GitHub API, searches open issues, and applies a simple score based on repository activity, issue activity, comments, labels, and related signals.
|
|
6
|
+
It is currently aimed at junior to intermediate developers who want a faster way to find approachable issues.
|
|
7
|
+
|
|
8
|
+
## Features
|
|
9
|
+
|
|
10
|
+
- Search GitHub open issues
|
|
11
|
+
- Filter by language, label, stars, and update recency
|
|
12
|
+
- Skip issues that already have linked PRs
|
|
13
|
+
- Recommend only unassigned issues by default
|
|
14
|
+
- Skip repositories with fewer than 100 stars by default
|
|
15
|
+
- Render results as `table`, `markdown`, or `json`
|
|
16
|
+
- No third-party dependencies
|
|
17
|
+
|
|
18
|
+
## Usage
|
|
19
|
+
|
|
20
|
+
```powershell
|
|
21
|
+
python -m pip install -e .
|
|
22
|
+
oss-issue-scout search --language python --label "good first issue" --limit 5
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Options
|
|
26
|
+
|
|
27
|
+
```text
|
|
28
|
+
--language Repository primary language, such as python
|
|
29
|
+
--stars-min Minimum repository stars; defaults to at least 100
|
|
30
|
+
--label Issue label, such as "good first issue"
|
|
31
|
+
--updated-days Issue updated within the last N days
|
|
32
|
+
--repo-updated-days Repository had issue activity within the last N days
|
|
33
|
+
--limit Number of results, default 10
|
|
34
|
+
--format Output format: table, markdown, json
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
Examples:
|
|
38
|
+
|
|
39
|
+
```powershell
|
|
40
|
+
oss-issue-scout search --language python
|
|
41
|
+
oss-issue-scout search --language python --label "help wanted" --stars-min 500 --limit 10
|
|
42
|
+
oss-issue-scout search --language python --format json
|
|
43
|
+
oss-issue-scout search --language "C++" --label "good first issue" --repo-updated-days 7
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Scoring
|
|
47
|
+
|
|
48
|
+
The current score is intentionally simple. It considers:
|
|
49
|
+
|
|
50
|
+
- Repository stars: moderately active repos get a boost; very large repos may be penalized
|
|
51
|
+
- Issue update recency: recently updated issues get a boost; stale issues are penalized
|
|
52
|
+
- Repository issue activity: recent issue activity gets a boost
|
|
53
|
+
- Beginner-friendly labels: `good first issue` / `help wanted` only add points when the repo has at least 3 open issues with those labels
|
|
54
|
+
- Comment count: low discussion volume gets a boost; long discussions are penalized
|
|
55
|
+
|
|
56
|
+
The search step filters out:
|
|
57
|
+
|
|
58
|
+
- Closed issues
|
|
59
|
+
- Archived repositories
|
|
60
|
+
- Issues with linked PRs
|
|
61
|
+
- Assigned issues
|
|
62
|
+
- Repositories with fewer than 100 stars
|
|
63
|
+
|
|
64
|
+
## Tests
|
|
65
|
+
|
|
66
|
+
```powershell
|
|
67
|
+
python -m unittest discover
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Tests use mocked GitHub responses and do not call the real GitHub API.
|
|
71
|
+
|
|
72
|
+
## Next
|
|
73
|
+
|
|
74
|
+
This project is still small. If it helps you, please consider giving it a ⭐. Discussions will be opened after the project reaches 16+ ⭐.
|
|
75
|
+
|
|
76
|
+
If you have suggestions or run into problems, please open an issue.
|
|
77
|
+
|
|
78
|
+
Future versions will continue to improve recommendation quality and usability.
|
|
79
|
+
|
|
80
|
+
## Contributors
|
|
81
|
+
|
|
82
|
+
<a href="https://github.com/Yong-yuan-X/oss-issue-scout/graphs/contributors">
|
|
83
|
+
<img src="https://contrib.rocks/image?repo=Yong-yuan-X/oss-issue-scout" alt="Contributors" />
|
|
84
|
+
</a>
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# oss-issue-scout
|
|
2
|
+
|
|
3
|
+
发现值得贡献的开源 issues
|
|
4
|
+
|
|
5
|
+
[English README](README.en.md)
|
|
6
|
+
|
|
7
|
+
当前版本会调用 GitHub API 搜索 open issues,并根据项目活跃度、issue 活跃度、评论数量、标签等信号做一个简单评分
|
|
8
|
+
它主要面向初中级开发者,用来快速筛出更可能适合贡献的 issue
|
|
9
|
+
|
|
10
|
+
## 功能
|
|
11
|
+
|
|
12
|
+
- 搜索 GitHub open issues
|
|
13
|
+
- 支持按语言、标签、stars、更新时间过滤
|
|
14
|
+
- 默认跳过已有关联 PR 的 issue
|
|
15
|
+
- 默认只推荐未指派的 issue
|
|
16
|
+
- 默认过滤 stars 少于 100 的 repo
|
|
17
|
+
- 支持 `table`、`markdown`、`json` 输出
|
|
18
|
+
- 不依赖第三方包
|
|
19
|
+
|
|
20
|
+
## 使用
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
python -m pip install -e .
|
|
24
|
+
oss-issue-scout search --language python --label "good first issue" --limit 5
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## 参数
|
|
28
|
+
|
|
29
|
+
```text
|
|
30
|
+
--language 仓库主要语言,例如 python
|
|
31
|
+
--stars-min 仓库最低 stars,默认至少 100
|
|
32
|
+
--label issue 标签,例如 "good first issue"
|
|
33
|
+
--updated-days 当前 issue 最近多少天内更新过
|
|
34
|
+
--repo-updated-days issue 所在 repo 最近多少天内有 issue 活动
|
|
35
|
+
--limit 返回数量,默认 10
|
|
36
|
+
--format 输出格式:table、markdown、json
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
示例:
|
|
40
|
+
|
|
41
|
+
```powershell
|
|
42
|
+
oss-issue-scout search --language python
|
|
43
|
+
oss-issue-scout search --language python --label "help wanted" --stars-min 500 --limit 10
|
|
44
|
+
oss-issue-scout search --language python --format json
|
|
45
|
+
oss-issue-scout search --language "C++" --label "good first issue" --repo-updated-days 7
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
## 推荐规则
|
|
49
|
+
|
|
50
|
+
当前评分比较简单,主要参考:
|
|
51
|
+
|
|
52
|
+
- repo stars:中等活跃项目加分,超大型项目可能扣分
|
|
53
|
+
- issue 更新时间:近期更新加分,长期未更新扣分
|
|
54
|
+
- repo issue 活动:近期有 issue 活动加分
|
|
55
|
+
- beginner 标签:当前 issue 有 `good first issue` / `help wanted`,且 repo 中至少有 3 个同类 open issues 时加分
|
|
56
|
+
- 评论数量:评论少加分,讨论过长扣分
|
|
57
|
+
|
|
58
|
+
搜索阶段会直接排除:
|
|
59
|
+
|
|
60
|
+
- closed issues
|
|
61
|
+
- archived repos
|
|
62
|
+
- 已 linked PR 的 issues
|
|
63
|
+
- 已指派 assignee 的 issues
|
|
64
|
+
- stars 少于 100 的 repos
|
|
65
|
+
|
|
66
|
+
## 测试
|
|
67
|
+
|
|
68
|
+
```powershell
|
|
69
|
+
python -m unittest discover
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
测试使用 mock 数据,不会请求真实 GitHub API
|
|
73
|
+
|
|
74
|
+
## 后续
|
|
75
|
+
|
|
76
|
+
现在项目使用的人还比较少,如果你觉得它对你有帮助,欢迎点一个 ⭐。达到 16+ ⭐ 后会开启 Discussions
|
|
77
|
+
|
|
78
|
+
如果有改进建议或使用问题,可以在 issues 中提出
|
|
79
|
+
|
|
80
|
+
后续会逐步进行版本迭代,继续优化推荐质量和使用体验
|
|
81
|
+
|
|
82
|
+
## 贡献者
|
|
83
|
+
|
|
84
|
+
<a href="https://github.com/Yong-yuan-X/oss-issue-scout/graphs/contributors">
|
|
85
|
+
<img src="https://contrib.rocks/image?repo=Yong-yuan-X/oss-issue-scout" alt="Contributors" />
|
|
86
|
+
</a>
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import sys
|
|
5
|
+
|
|
6
|
+
from .github_api import GitHubAPIError, search_issues
|
|
7
|
+
from .output import render_results
|
|
8
|
+
from .scoring import score_issues
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
12
|
+
parser = argparse.ArgumentParser(prog="oss-issue-scout")
|
|
13
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
14
|
+
|
|
15
|
+
search_parser = subparsers.add_parser("search", help="search GitHub issues")
|
|
16
|
+
search_parser.add_argument("--language")
|
|
17
|
+
search_parser.add_argument("--stars-min", type=int)
|
|
18
|
+
search_parser.add_argument("--label")
|
|
19
|
+
search_parser.add_argument("--updated-days", type=int)
|
|
20
|
+
search_parser.add_argument("--repo-updated-days", type=int)
|
|
21
|
+
search_parser.add_argument("--limit", type=_positive_int, default=10)
|
|
22
|
+
search_parser.add_argument(
|
|
23
|
+
"--format",
|
|
24
|
+
choices=("table", "markdown", "json"),
|
|
25
|
+
default="table",
|
|
26
|
+
)
|
|
27
|
+
search_parser.set_defaults(func=_run_search)
|
|
28
|
+
return parser
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _positive_int(value: str) -> int:
|
|
32
|
+
parsed = int(value)
|
|
33
|
+
if parsed <= 0:
|
|
34
|
+
raise argparse.ArgumentTypeError("must be greater than 0")
|
|
35
|
+
return parsed
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _run_search(args: argparse.Namespace) -> int:
|
|
39
|
+
try:
|
|
40
|
+
issues = search_issues(
|
|
41
|
+
language=args.language,
|
|
42
|
+
stars_min=args.stars_min,
|
|
43
|
+
label=args.label,
|
|
44
|
+
updated_days=args.updated_days,
|
|
45
|
+
repo_updated_days=args.repo_updated_days,
|
|
46
|
+
limit=args.limit,
|
|
47
|
+
)
|
|
48
|
+
except GitHubAPIError as error:
|
|
49
|
+
print(f"error: {error}", file=sys.stderr)
|
|
50
|
+
return 1
|
|
51
|
+
results = score_issues(issues)[: args.limit]
|
|
52
|
+
print(render_results(results, args.format))
|
|
53
|
+
return 0
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def main(argv: list[str] | None = None) -> int:
|
|
57
|
+
parser = build_parser()
|
|
58
|
+
args = parser.parse_args(argv)
|
|
59
|
+
return args.func(args)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime, timedelta, timezone
|
|
7
|
+
from typing import Any
|
|
8
|
+
from urllib.error import HTTPError, URLError
|
|
9
|
+
from urllib.parse import quote, urlencode
|
|
10
|
+
from urllib.request import Request, urlopen
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
GITHUB_API_BASE = "https://api.github.com"
|
|
14
|
+
GITHUB_API_VERSION = "2026-03-10"
|
|
15
|
+
DEFAULT_STARS_MIN = 100
|
|
16
|
+
MAX_SEARCH_PAGES = 5
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class GitHubAPIError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass(frozen=True)
|
|
24
|
+
class Issue:
|
|
25
|
+
repo: str
|
|
26
|
+
title: str
|
|
27
|
+
url: str
|
|
28
|
+
language: str
|
|
29
|
+
stars: int
|
|
30
|
+
labels: tuple[str, ...]
|
|
31
|
+
updated_days: int
|
|
32
|
+
repo_last_issue_updated_days: int
|
|
33
|
+
repo_beginner_issue_count: int
|
|
34
|
+
comments: int
|
|
35
|
+
has_open_pr: bool
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def search_issues(
|
|
39
|
+
*,
|
|
40
|
+
language: str | None = None,
|
|
41
|
+
stars_min: int | None = None,
|
|
42
|
+
label: str | None = None,
|
|
43
|
+
updated_days: int | None = None,
|
|
44
|
+
repo_updated_days: int | None = None,
|
|
45
|
+
limit: int = 10,
|
|
46
|
+
) -> list[Issue]:
|
|
47
|
+
if limit <= 0:
|
|
48
|
+
return []
|
|
49
|
+
|
|
50
|
+
effective_stars_min = max(stars_min or DEFAULT_STARS_MIN, DEFAULT_STARS_MIN)
|
|
51
|
+
|
|
52
|
+
query = _build_issue_query(
|
|
53
|
+
language=language,
|
|
54
|
+
stars_min=effective_stars_min,
|
|
55
|
+
label=label,
|
|
56
|
+
updated_days=updated_days,
|
|
57
|
+
)
|
|
58
|
+
repo_cache: dict[str, dict[str, Any]] = {}
|
|
59
|
+
repo_activity_cache: dict[str, int] = {}
|
|
60
|
+
repo_beginner_issue_count_cache: dict[str, int] = {}
|
|
61
|
+
issues: list[Issue] = []
|
|
62
|
+
# Search candidates match the GitHub query first, then local filters below
|
|
63
|
+
# enforce repo metadata and repo activity rules.
|
|
64
|
+
per_page = min(max(limit * 10, 30), 100)
|
|
65
|
+
|
|
66
|
+
for page in range(1, MAX_SEARCH_PAGES + 1):
|
|
67
|
+
data = _request_json(
|
|
68
|
+
"/search/issues",
|
|
69
|
+
{
|
|
70
|
+
"q": query,
|
|
71
|
+
"sort": "updated",
|
|
72
|
+
"order": "desc",
|
|
73
|
+
"per_page": str(per_page),
|
|
74
|
+
"page": str(page),
|
|
75
|
+
},
|
|
76
|
+
)
|
|
77
|
+
items = data.get("items", [])
|
|
78
|
+
if not items:
|
|
79
|
+
break
|
|
80
|
+
|
|
81
|
+
for item in items:
|
|
82
|
+
if "pull_request" in item:
|
|
83
|
+
continue
|
|
84
|
+
|
|
85
|
+
repo = _repo_full_name(item)
|
|
86
|
+
if repo is None:
|
|
87
|
+
continue
|
|
88
|
+
|
|
89
|
+
if repo not in repo_cache:
|
|
90
|
+
repo_cache[repo] = _repo_info_from_search_result(item) or _get_repo(repo)
|
|
91
|
+
repo_info = repo_cache[repo]
|
|
92
|
+
stars = int(repo_info.get("stargazers_count") or 0)
|
|
93
|
+
if stars < effective_stars_min:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
repo_language = str(repo_info.get("language") or "")
|
|
97
|
+
if language and repo_language.casefold() != language.casefold():
|
|
98
|
+
continue
|
|
99
|
+
|
|
100
|
+
if repo not in repo_activity_cache:
|
|
101
|
+
repo_activity_cache[repo] = _get_repo_last_issue_updated_days(repo)
|
|
102
|
+
if repo not in repo_beginner_issue_count_cache:
|
|
103
|
+
repo_beginner_issue_count_cache[repo] = _get_repo_beginner_issue_count(repo)
|
|
104
|
+
if (
|
|
105
|
+
repo_updated_days is not None
|
|
106
|
+
and repo_activity_cache[repo] > repo_updated_days
|
|
107
|
+
):
|
|
108
|
+
continue
|
|
109
|
+
|
|
110
|
+
issue = Issue(
|
|
111
|
+
repo=repo,
|
|
112
|
+
title=str(item.get("title") or ""),
|
|
113
|
+
url=str(item.get("html_url") or ""),
|
|
114
|
+
language=repo_language,
|
|
115
|
+
stars=stars,
|
|
116
|
+
labels=_labels(item),
|
|
117
|
+
updated_days=_days_since(str(item.get("updated_at") or "")),
|
|
118
|
+
repo_last_issue_updated_days=repo_activity_cache[repo],
|
|
119
|
+
repo_beginner_issue_count=repo_beginner_issue_count_cache[repo],
|
|
120
|
+
comments=int(item.get("comments") or 0),
|
|
121
|
+
has_open_pr=False,
|
|
122
|
+
)
|
|
123
|
+
issues.append(issue)
|
|
124
|
+
if len(issues) >= limit:
|
|
125
|
+
break
|
|
126
|
+
|
|
127
|
+
if len(issues) >= limit or len(items) < per_page:
|
|
128
|
+
break
|
|
129
|
+
|
|
130
|
+
return issues
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _build_issue_query(
|
|
134
|
+
*,
|
|
135
|
+
language: str | None,
|
|
136
|
+
stars_min: int | None,
|
|
137
|
+
label: str | None,
|
|
138
|
+
updated_days: int | None,
|
|
139
|
+
) -> str:
|
|
140
|
+
parts = ["is:issue", "is:open", "archived:false", "-linked:pr", "no:assignee"]
|
|
141
|
+
if language:
|
|
142
|
+
parts.append(f"language:{_quote_query_value(language)}")
|
|
143
|
+
if label:
|
|
144
|
+
parts.append(f"label:{_quote_query_value(label)}")
|
|
145
|
+
if updated_days is not None:
|
|
146
|
+
cutoff = datetime.now(timezone.utc).date() - timedelta(days=updated_days)
|
|
147
|
+
parts.append(f"updated:>={cutoff.isoformat()}")
|
|
148
|
+
return " ".join(parts)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _request_json(path: str, params: dict[str, str] | None = None) -> dict[str, Any]:
|
|
152
|
+
url = f"{GITHUB_API_BASE}{path}"
|
|
153
|
+
if params:
|
|
154
|
+
url = f"{url}?{urlencode(params)}"
|
|
155
|
+
|
|
156
|
+
headers = {
|
|
157
|
+
"Accept": "application/vnd.github+json",
|
|
158
|
+
"X-GitHub-Api-Version": GITHUB_API_VERSION,
|
|
159
|
+
"User-Agent": "oss-issue-scout",
|
|
160
|
+
}
|
|
161
|
+
token = os.environ.get("GITHUB_TOKEN")
|
|
162
|
+
if token:
|
|
163
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
164
|
+
|
|
165
|
+
request = Request(url, headers=headers)
|
|
166
|
+
try:
|
|
167
|
+
with urlopen(request, timeout=20) as response:
|
|
168
|
+
return json.loads(response.read().decode("utf-8"))
|
|
169
|
+
except HTTPError as error:
|
|
170
|
+
message = error.read().decode("utf-8", errors="replace")
|
|
171
|
+
if error.code == 403 and "rate limit" in message.casefold():
|
|
172
|
+
raise GitHubAPIError(
|
|
173
|
+
"GitHub API rate limit exceeded. Set a GITHUB_TOKEN environment "
|
|
174
|
+
"variable to get a higher rate limit, then try again."
|
|
175
|
+
) from error
|
|
176
|
+
raise GitHubAPIError(f"GitHub API request failed: HTTP {error.code} {message}") from error
|
|
177
|
+
except URLError as error:
|
|
178
|
+
raise GitHubAPIError(f"GitHub API request failed: {error.reason}") from error
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def _get_repo(repo: str) -> dict[str, Any]:
|
|
182
|
+
owner, name = repo.split("/", 1)
|
|
183
|
+
return _request_json(f"/repos/{quote(owner)}/{quote(name)}")
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _get_repo_last_issue_updated_days(repo: str) -> int:
|
|
187
|
+
data = _request_json(
|
|
188
|
+
"/search/issues",
|
|
189
|
+
{
|
|
190
|
+
"q": f"repo:{repo} is:issue",
|
|
191
|
+
"sort": "updated",
|
|
192
|
+
"order": "desc",
|
|
193
|
+
"per_page": "1",
|
|
194
|
+
},
|
|
195
|
+
)
|
|
196
|
+
items = data.get("items", [])
|
|
197
|
+
if not items:
|
|
198
|
+
return 9999
|
|
199
|
+
return _days_since(str(items[0].get("updated_at") or ""))
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _get_repo_beginner_issue_count(repo: str) -> int:
|
|
203
|
+
data = _request_json(
|
|
204
|
+
"/search/issues",
|
|
205
|
+
{
|
|
206
|
+
"q": f'repo:{repo} is:issue is:open label:"good first issue","help wanted"',
|
|
207
|
+
"per_page": "1",
|
|
208
|
+
},
|
|
209
|
+
)
|
|
210
|
+
return int(data.get("total_count") or 0)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _repo_full_name(item: dict[str, Any]) -> str | None:
|
|
214
|
+
repository_url = str(item.get("repository_url") or "")
|
|
215
|
+
marker = f"{GITHUB_API_BASE}/repos/"
|
|
216
|
+
if repository_url.startswith(marker):
|
|
217
|
+
return repository_url.removeprefix(marker)
|
|
218
|
+
repository = item.get("repository")
|
|
219
|
+
if isinstance(repository, dict):
|
|
220
|
+
full_name = repository.get("full_name")
|
|
221
|
+
if isinstance(full_name, str):
|
|
222
|
+
return full_name
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _repo_info_from_search_result(item: dict[str, Any]) -> dict[str, Any] | None:
|
|
227
|
+
repository = item.get("repository")
|
|
228
|
+
if not isinstance(repository, dict):
|
|
229
|
+
return None
|
|
230
|
+
if "stargazers_count" not in repository or "language" not in repository:
|
|
231
|
+
return None
|
|
232
|
+
return repository
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _labels(item: dict[str, Any]) -> tuple[str, ...]:
|
|
236
|
+
labels = item.get("labels") or []
|
|
237
|
+
names: list[str] = []
|
|
238
|
+
for label in labels:
|
|
239
|
+
if isinstance(label, dict) and isinstance(label.get("name"), str):
|
|
240
|
+
names.append(label["name"])
|
|
241
|
+
return tuple(names)
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _days_since(value: str) -> int:
|
|
245
|
+
if not value:
|
|
246
|
+
return 9999
|
|
247
|
+
timestamp = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
248
|
+
delta = datetime.now(timezone.utc) - timestamp
|
|
249
|
+
return max(delta.days, 0)
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _quote_query_value(value: str) -> str:
|
|
253
|
+
if " " in value:
|
|
254
|
+
return f'"{value}"'
|
|
255
|
+
return value
|