certainty-units 0.2.0
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/LICENSE +21 -0
- package/README.md +184 -0
- package/examples/cu-report.yml +48 -0
- package/examples/github.yaml +15 -0
- package/examples/jira.yaml +23 -0
- package/examples/linear.yaml +25 -0
- package/examples/notion.yaml +28 -0
- package/package.json +35 -0
- package/src/adapters/github.js +96 -0
- package/src/adapters/jira.js +122 -0
- package/src/adapters/linear.js +130 -0
- package/src/adapters/notion.js +143 -0
- package/src/adapters/util.js +90 -0
- package/src/certainty.js +152 -0
- package/src/cli.js +224 -0
- package/src/config.js +75 -0
- package/src/history.js +55 -0
- package/src/report.js +200 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Nhan Nguyen
|
|
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.
|
package/README.md
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
# certainty-units
|
|
2
|
+
|
|
3
|
+
Free, open-source certainty scoring for project work items.
|
|
4
|
+
Connects to **GitHub Issues, Linear, Jira, Notion** (Monday + ClickUp coming).
|
|
5
|
+
Outputs a static HTML dashboard + Markdown report — zero server required.
|
|
6
|
+
|
|
7
|
+
```
|
|
8
|
+
npx certainty-units sync
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Teams that track success by counting effort (points, tickets closed) see *how much* is moving —
|
|
12
|
+
certainty-units shows *how sure you are* about what's moving, and what would make you surer.
|
|
13
|
+
|
|
14
|
+
## Quick start
|
|
15
|
+
|
|
16
|
+
Fastest path — score any public GitHub repo, no API key needed:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
npx certainty-units init
|
|
20
|
+
# edit certainty.config.yaml:
|
|
21
|
+
# source: github
|
|
22
|
+
# github:
|
|
23
|
+
# repo: your-org/your-repo
|
|
24
|
+
npx certainty-units sync
|
|
25
|
+
|
|
26
|
+
# Output: cu-report.html (open in any browser)
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
For Linear / Jira / Notion, set the API key env var and run the same command:
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
LINEAR_API_KEY=lin_api_xxx npx certainty-units sync
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
Every sync prints instant insights in the terminal:
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
278 items · avg certainty 56%
|
|
39
|
+
high 68 medium 116 low 54 uncertain 40
|
|
40
|
+
|
|
41
|
+
Since last sync (2026-06-27): avg 54% → 56% ▲ +2 · 12 new items
|
|
42
|
+
▼ 45 → 20 ENG-142 Add SSO support
|
|
43
|
+
▲ 30 → 80 ENG-98 Migrate billing
|
|
44
|
+
|
|
45
|
+
Least certain open items:
|
|
46
|
+
5% #1099 always_allow permission policy not honored
|
|
47
|
+
missing: validation +40, workflow +20, discussion +15
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## What it scores
|
|
51
|
+
|
|
52
|
+
Each work item gets a **Certainty Score (0–100)**:
|
|
53
|
+
|
|
54
|
+
| Signal | Max points | How it maps |
|
|
55
|
+
|--------|-----------|-------------|
|
|
56
|
+
| Validation status | 40 | `validated` → 40, `assumed` → 10 |
|
|
57
|
+
| Workflow progress | 20 | `done` → 20, `review` → 15, `in_progress` → 10 |
|
|
58
|
+
| Discussion | up to 15 | 5 pts per comment — undebated items hide risk |
|
|
59
|
+
| Acceptance criteria | 10 | found on the item (see below) |
|
|
60
|
+
| CU tier set | 10 | basic / intermediate / advanced |
|
|
61
|
+
| Evidence / description | 5 | non-empty description |
|
|
62
|
+
|
|
63
|
+
Scores map to levels: **high** (≥80) · **medium** (≥50) · **low** (≥20) · **uncertain** (<20)
|
|
64
|
+
|
|
65
|
+
Every score is auditable: hover the score bar in the HTML report for the per-signal
|
|
66
|
+
breakdown, and `cu-data.json` (via `--json`) includes `certainty_breakdown` per item.
|
|
67
|
+
|
|
68
|
+
## Feed it real signals
|
|
69
|
+
|
|
70
|
+
The score is only as honest as its inputs, so the adapters read signals your team
|
|
71
|
+
already produces:
|
|
72
|
+
|
|
73
|
+
- **Acceptance criteria** are detected automatically from an `## Acceptance Criteria`
|
|
74
|
+
heading (or `**Acceptance Criteria**`, or an `Acceptance criteria:` line) in the
|
|
75
|
+
item description, or from a markdown checklist (`- [ ] …`). In Notion, use an
|
|
76
|
+
`Acceptance Criteria` column.
|
|
77
|
+
- **Validation labels** override workflow-derived validation. Label an item
|
|
78
|
+
`validated`, `assumed`, or `needs-clarification` (Notion: tags) to record validation
|
|
79
|
+
explicitly instead of letting "closed" imply "validated".
|
|
80
|
+
- **CU tier labels** — `cu:basic`, `cu:intermediate`, `cu:advanced` — set the tier
|
|
81
|
+
directly on any tool that has labels.
|
|
82
|
+
|
|
83
|
+
## Track movement, not just state
|
|
84
|
+
|
|
85
|
+
Each sync snapshots per-item scores to `.cu-history.json` and the next sync reports
|
|
86
|
+
what moved: average trend, biggest drops and rises, new items. Commit the file (or
|
|
87
|
+
cache it in CI) to keep the trail. Disable with `--no-history`.
|
|
88
|
+
|
|
89
|
+
## Use it as a CI gate
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
certainty-units sync --fail-below 50 # exit 1 if avg certainty < 50%
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
Put it in front of a sprint kickoff or release train to block on too much uncertainty.
|
|
96
|
+
|
|
97
|
+
## Post to Slack
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
certainty-units sync --slack-webhook https://hooks.slack.com/services/…
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
Posts the summary and least-certain items to a channel (or set `output.slackWebhook`
|
|
104
|
+
in the config).
|
|
105
|
+
|
|
106
|
+
## Supported sources
|
|
107
|
+
|
|
108
|
+
| Tool | Status | Auth |
|
|
109
|
+
|------|--------|------|
|
|
110
|
+
| GitHub Issues | ✅ | none for public repos, token for private |
|
|
111
|
+
| Linear | ✅ | API key |
|
|
112
|
+
| Jira | ✅ | API token |
|
|
113
|
+
| Notion | ✅ | API key |
|
|
114
|
+
| Monday.com | 🚧 coming | |
|
|
115
|
+
| ClickUp | 🚧 coming | |
|
|
116
|
+
|
|
117
|
+
## Config reference
|
|
118
|
+
|
|
119
|
+
```yaml
|
|
120
|
+
project: My Project
|
|
121
|
+
source: github # github | linear | jira | notion
|
|
122
|
+
|
|
123
|
+
github:
|
|
124
|
+
repo: owner/name
|
|
125
|
+
token: ${GITHUB_TOKEN} # optional for public repos
|
|
126
|
+
# state: all # all | open | closed
|
|
127
|
+
# limit: 500 # most recently updated issues to fetch
|
|
128
|
+
|
|
129
|
+
linear:
|
|
130
|
+
apiKey: ${LINEAR_API_KEY} # env var expansion supported
|
|
131
|
+
teamId: your-team-id
|
|
132
|
+
|
|
133
|
+
jira:
|
|
134
|
+
host: yourteam.atlassian.net
|
|
135
|
+
email: you@example.com
|
|
136
|
+
apiToken: ${JIRA_API_TOKEN}
|
|
137
|
+
projectKey: MYPROJECT
|
|
138
|
+
|
|
139
|
+
notion:
|
|
140
|
+
apiKey: ${NOTION_API_KEY}
|
|
141
|
+
databaseId: your-database-id
|
|
142
|
+
|
|
143
|
+
output:
|
|
144
|
+
html: cu-report.html
|
|
145
|
+
markdown: cu-report.md # optional
|
|
146
|
+
history: .cu-history.json # optional — score snapshots for deltas
|
|
147
|
+
# slackWebhook: https://hooks.slack.com/services/…
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
### Field mapping
|
|
151
|
+
|
|
152
|
+
Every tool uses different status names. Override the defaults:
|
|
153
|
+
|
|
154
|
+
```yaml
|
|
155
|
+
linear:
|
|
156
|
+
apiKey: ${LINEAR_API_KEY}
|
|
157
|
+
teamId: abc123
|
|
158
|
+
fieldMap:
|
|
159
|
+
validation_status:
|
|
160
|
+
field: state.type # dot-path into the Linear issue object
|
|
161
|
+
map:
|
|
162
|
+
completed: validated
|
|
163
|
+
started: assumed
|
|
164
|
+
cu_tier:
|
|
165
|
+
field: estimate
|
|
166
|
+
map:
|
|
167
|
+
1: basic
|
|
168
|
+
3: intermediate
|
|
169
|
+
8: advanced
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
## Automate with GitHub Actions
|
|
173
|
+
|
|
174
|
+
1. Copy [examples/cu-report.yml](examples/cu-report.yml) to `.github/workflows/` in your repo
|
|
175
|
+
2. Add your API key as a repo secret (`LINEAR_API_KEY`, or nothing for public GitHub repos)
|
|
176
|
+
3. Enable GitHub Pages → the report publishes every Monday, with week-over-week deltas
|
|
177
|
+
|
|
178
|
+
## License
|
|
179
|
+
|
|
180
|
+
MIT — free to use, modify, and embed.
|
|
181
|
+
|
|
182
|
+
---
|
|
183
|
+
|
|
184
|
+
Built on the [Certainty Units](https://propozel.com) methodology.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
name: Certainty Units Report
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
schedule:
|
|
5
|
+
- cron: '0 8 * * 1' # every Monday at 8am UTC
|
|
6
|
+
workflow_dispatch: # also runnable manually
|
|
7
|
+
|
|
8
|
+
permissions:
|
|
9
|
+
contents: write
|
|
10
|
+
|
|
11
|
+
jobs:
|
|
12
|
+
report:
|
|
13
|
+
runs-on: ubuntu-latest
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-node@v4
|
|
18
|
+
with:
|
|
19
|
+
node-version: '20'
|
|
20
|
+
|
|
21
|
+
# Keep score history across runs so each report shows what moved
|
|
22
|
+
- name: Restore sync history
|
|
23
|
+
uses: actions/cache@v4
|
|
24
|
+
with:
|
|
25
|
+
path: .cu-history.json
|
|
26
|
+
key: cu-history-${{ github.run_id }}
|
|
27
|
+
restore-keys: cu-history-
|
|
28
|
+
|
|
29
|
+
- name: Generate report
|
|
30
|
+
env:
|
|
31
|
+
LINEAR_API_KEY: ${{ secrets.LINEAR_API_KEY }}
|
|
32
|
+
# JIRA_API_TOKEN: ${{ secrets.JIRA_API_TOKEN }}
|
|
33
|
+
# NOTION_API_KEY: ${{ secrets.NOTION_API_KEY }}
|
|
34
|
+
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # for source: github
|
|
35
|
+
run: |
|
|
36
|
+
npx certainty-units@latest sync --json
|
|
37
|
+
# add --slack-webhook ${{ secrets.SLACK_WEBHOOK }} to post the summary
|
|
38
|
+
# add --fail-below 50 to fail this job when avg certainty drops too low
|
|
39
|
+
|
|
40
|
+
# Publish only the report, never the repo checkout
|
|
41
|
+
- name: Stage report
|
|
42
|
+
run: mkdir -p public && cp cu-report.html public/index.html
|
|
43
|
+
|
|
44
|
+
- name: Publish to GitHub Pages
|
|
45
|
+
uses: peaceiris/actions-gh-pages@v4
|
|
46
|
+
with:
|
|
47
|
+
github_token: ${{ secrets.GITHUB_TOKEN }}
|
|
48
|
+
publish_dir: ./public
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
project: My GitHub Project
|
|
2
|
+
source: github
|
|
3
|
+
|
|
4
|
+
github:
|
|
5
|
+
repo: owner/name # e.g. vercel/next.js
|
|
6
|
+
# token: ${GITHUB_TOKEN} # uncomment for private repos / higher rate limits
|
|
7
|
+
|
|
8
|
+
# GitHub has no priority field — set CU tier with labels on your issues:
|
|
9
|
+
# cu:basic cu:intermediate cu:advanced
|
|
10
|
+
# And record validation explicitly with labels:
|
|
11
|
+
# validated assumed needs-clarification
|
|
12
|
+
|
|
13
|
+
output:
|
|
14
|
+
html: cu-report.html
|
|
15
|
+
markdown: cu-report.md
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
project: My Jira Project
|
|
2
|
+
source: jira
|
|
3
|
+
|
|
4
|
+
jira:
|
|
5
|
+
host: yourteam.atlassian.net
|
|
6
|
+
email: you@example.com
|
|
7
|
+
apiToken: ${JIRA_API_TOKEN} # create at id.atlassian.com/manage-profile/security/api-tokens
|
|
8
|
+
projectKey: MYPROJECT
|
|
9
|
+
|
|
10
|
+
# Uncomment to override default field mappings
|
|
11
|
+
# jql: 'project = MYPROJECT AND sprint in openSprints()' # custom JQL filter
|
|
12
|
+
# fieldMap:
|
|
13
|
+
# validation_status:
|
|
14
|
+
# field: fields.status.name
|
|
15
|
+
# map:
|
|
16
|
+
# "Done": validated
|
|
17
|
+
# "In Review": assumed
|
|
18
|
+
# "In Progress": assumed
|
|
19
|
+
# "To Do": unvalidated
|
|
20
|
+
|
|
21
|
+
output:
|
|
22
|
+
html: cu-report.html
|
|
23
|
+
markdown: cu-report.md
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
project: My Linear Project
|
|
2
|
+
source: linear
|
|
3
|
+
|
|
4
|
+
linear:
|
|
5
|
+
apiKey: ${LINEAR_API_KEY}
|
|
6
|
+
teamId: your-team-id # find at linear.app/settings → your team URL
|
|
7
|
+
|
|
8
|
+
# Uncomment and adjust to match your team's field conventions
|
|
9
|
+
# fieldMap:
|
|
10
|
+
# cu_tier:
|
|
11
|
+
# field: estimate # use story points as CU tier proxy
|
|
12
|
+
# map:
|
|
13
|
+
# 1: basic
|
|
14
|
+
# 3: intermediate
|
|
15
|
+
# 8: advanced
|
|
16
|
+
# validation_status:
|
|
17
|
+
# field: state.type
|
|
18
|
+
# map:
|
|
19
|
+
# completed: validated
|
|
20
|
+
# started: assumed
|
|
21
|
+
# triage: needs_clarification
|
|
22
|
+
|
|
23
|
+
output:
|
|
24
|
+
html: cu-report.html
|
|
25
|
+
markdown: cu-report.md
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
project: My Notion Project
|
|
2
|
+
source: notion
|
|
3
|
+
|
|
4
|
+
notion:
|
|
5
|
+
apiKey: ${NOTION_API_KEY} # create at notion.so/my-integrations
|
|
6
|
+
databaseId: your-database-id # from the database URL: notion.so/xxx/<database-id>?v=...
|
|
7
|
+
|
|
8
|
+
fieldMap:
|
|
9
|
+
title_field: { field: Name } # column that holds the item title
|
|
10
|
+
assignee_field: { field: Assignee }
|
|
11
|
+
estimate_field: { field: Estimate } # number column for CU value
|
|
12
|
+
validation_status:
|
|
13
|
+
field: Status # your Status column name
|
|
14
|
+
map:
|
|
15
|
+
Done: validated
|
|
16
|
+
"In Progress": assumed
|
|
17
|
+
"To Do": unvalidated
|
|
18
|
+
Backlog: unvalidated
|
|
19
|
+
cu_tier:
|
|
20
|
+
field: Priority # your Priority column name
|
|
21
|
+
map:
|
|
22
|
+
High: advanced
|
|
23
|
+
Medium: intermediate
|
|
24
|
+
Low: basic
|
|
25
|
+
|
|
26
|
+
output:
|
|
27
|
+
html: cu-report.html
|
|
28
|
+
markdown: cu-report.md
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "certainty-units",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "Certainty scoring for project work items — connects to Linear, Jira, Notion, GitHub Issues",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"certainty-units": "./src/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"src",
|
|
11
|
+
"examples",
|
|
12
|
+
"LICENSE",
|
|
13
|
+
"README.md"
|
|
14
|
+
],
|
|
15
|
+
"scripts": {
|
|
16
|
+
"test": "node --test"
|
|
17
|
+
},
|
|
18
|
+
"keywords": ["project-management", "certainty", "linear", "jira", "notion", "github", "agile", "hill-chart"],
|
|
19
|
+
"license": "MIT",
|
|
20
|
+
"author": "Nhan Nguyen <nhan@naucode.com>",
|
|
21
|
+
"repository": {
|
|
22
|
+
"type": "git",
|
|
23
|
+
"url": "git+https://github.com/2ngnhan/certainty-units.git"
|
|
24
|
+
},
|
|
25
|
+
"homepage": "https://github.com/2ngnhan/certainty-units#readme",
|
|
26
|
+
"bugs": "https://github.com/2ngnhan/certainty-units/issues",
|
|
27
|
+
"dependencies": {
|
|
28
|
+
"chalk": "^5.3.0",
|
|
29
|
+
"commander": "^12.0.0",
|
|
30
|
+
"js-yaml": "^4.1.0"
|
|
31
|
+
},
|
|
32
|
+
"engines": {
|
|
33
|
+
"node": ">=18"
|
|
34
|
+
}
|
|
35
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// GitHub Issues adapter — maps repo issues to CU items via REST API.
|
|
2
|
+
// Works without a token on public repos (rate-limited); set github.token for
|
|
3
|
+
// private repos or higher limits.
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
applyMap, mergeFieldMap,
|
|
7
|
+
extractAcceptanceCriteria, validationFromLabels, tierFromLabels,
|
|
8
|
+
} from './util.js'
|
|
9
|
+
|
|
10
|
+
const GITHUB_API = 'https://api.github.com'
|
|
11
|
+
|
|
12
|
+
async function githubFetch(url, token) {
|
|
13
|
+
const headers = {
|
|
14
|
+
Accept: 'application/vnd.github+json',
|
|
15
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
16
|
+
}
|
|
17
|
+
if (token) headers.Authorization = `Bearer ${token}`
|
|
18
|
+
const res = await fetch(url, { headers })
|
|
19
|
+
if (!res.ok) throw new Error(`GitHub API ${res.status}: ${await res.text()}`)
|
|
20
|
+
// cursor-based pagination: GitHub puts the next-page URL in the Link header
|
|
21
|
+
const next = res.headers.get('link')?.match(/<([^>]+)>;\s*rel="next"/)?.[1] ?? null
|
|
22
|
+
return { data: await res.json(), next }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// Most recently updated first, capped so a huge backlog doesn't hang the sync.
|
|
26
|
+
async function fetchAllIssues(repo, token, { state = 'all', limit = 500 } = {}) {
|
|
27
|
+
const issues = []
|
|
28
|
+
let url = `${GITHUB_API}/repos/${repo}/issues?state=${state}&sort=updated&direction=desc&per_page=100`
|
|
29
|
+
while (url && issues.length < limit) {
|
|
30
|
+
const { data, next } = await githubFetch(url, token)
|
|
31
|
+
// the issues endpoint also returns pull requests — skip them
|
|
32
|
+
issues.push(...data.filter(i => !i.pull_request))
|
|
33
|
+
url = next
|
|
34
|
+
}
|
|
35
|
+
return issues.slice(0, limit)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const DEFAULT_MAP = {
|
|
39
|
+
validation_status: {
|
|
40
|
+
field: 'state_reason',
|
|
41
|
+
map: {
|
|
42
|
+
completed: 'validated',
|
|
43
|
+
not_planned: 'unvalidated',
|
|
44
|
+
reopened: 'assumed',
|
|
45
|
+
},
|
|
46
|
+
default: 'unvalidated',
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function workflowStatus(issue) {
|
|
51
|
+
if (issue.state === 'closed') return 'done'
|
|
52
|
+
return issue.assignee ? 'in_progress' : 'todo'
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function fetchItems(config) {
|
|
56
|
+
const { repo, token, state, limit } = config
|
|
57
|
+
if (!repo || !repo.includes('/')) {
|
|
58
|
+
throw new Error('github.repo is required (e.g. owner/name)')
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const effectiveLimit = limit ?? 500
|
|
62
|
+
const issues = await fetchAllIssues(repo, token, { state, limit: effectiveLimit })
|
|
63
|
+
if (issues.length >= effectiveLimit) {
|
|
64
|
+
console.error(` note: capped at ${issues.length} most recently updated issues (set github.limit to raise)`)
|
|
65
|
+
}
|
|
66
|
+
const fieldMap = mergeFieldMap(DEFAULT_MAP, config.fieldMap)
|
|
67
|
+
|
|
68
|
+
return issues.map(issue => {
|
|
69
|
+
const labels = issue.labels?.map(l => (typeof l === 'string' ? l : l.name)) ?? []
|
|
70
|
+
const body = issue.body || ''
|
|
71
|
+
|
|
72
|
+
return {
|
|
73
|
+
id: String(issue.id),
|
|
74
|
+
external_id: `#${issue.number}`,
|
|
75
|
+
title: issue.title,
|
|
76
|
+
description: body,
|
|
77
|
+
url: issue.html_url,
|
|
78
|
+
// an explicit validation label beats inferring validation from issue state
|
|
79
|
+
validation_status: validationFromLabels(labels) ?? applyMap(issue, fieldMap.validation_status),
|
|
80
|
+
workflow_status: workflowStatus(issue),
|
|
81
|
+
cu_tier: tierFromLabels(labels),
|
|
82
|
+
cu_value: 1,
|
|
83
|
+
evidence: body,
|
|
84
|
+
acceptance_criteria: extractAcceptanceCriteria(body),
|
|
85
|
+
novelty_rating: null,
|
|
86
|
+
complexity_rating: null,
|
|
87
|
+
dependency_rating: null,
|
|
88
|
+
citation_count: issue.comments ?? 0,
|
|
89
|
+
assignee: issue.assignee?.login ?? null,
|
|
90
|
+
labels,
|
|
91
|
+
created_at: issue.created_at,
|
|
92
|
+
updated_at: issue.updated_at,
|
|
93
|
+
source: 'github',
|
|
94
|
+
}
|
|
95
|
+
})
|
|
96
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// Jira adapter — maps Jira issues to CU items via REST API
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
applyMap, mergeFieldMap,
|
|
5
|
+
extractAcceptanceCriteria, validationFromLabels, tierFromLabels,
|
|
6
|
+
} from './util.js'
|
|
7
|
+
|
|
8
|
+
function base64(str) {
|
|
9
|
+
return Buffer.from(str).toString('base64')
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function jiraFetch(config, path) {
|
|
13
|
+
const { host, email, apiToken } = config
|
|
14
|
+
const url = `https://${host}/rest/api/3${path}`
|
|
15
|
+
const auth = base64(`${email}:${apiToken}`)
|
|
16
|
+
const res = await fetch(url, {
|
|
17
|
+
headers: { Authorization: `Basic ${auth}`, Accept: 'application/json' },
|
|
18
|
+
})
|
|
19
|
+
if (!res.ok) throw new Error(`Jira API ${res.status}: ${await res.text()}`)
|
|
20
|
+
return res.json()
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
async function fetchAllIssues(config) {
|
|
24
|
+
const { projectKey, jql: customJql } = config
|
|
25
|
+
const jql = customJql ?? `project = "${projectKey}" ORDER BY updated DESC`
|
|
26
|
+
const issues = []
|
|
27
|
+
let startAt = 0
|
|
28
|
+
const maxResults = 100
|
|
29
|
+
|
|
30
|
+
do {
|
|
31
|
+
const data = await jiraFetch(
|
|
32
|
+
config,
|
|
33
|
+
`/search?jql=${encodeURIComponent(jql)}&startAt=${startAt}&maxResults=${maxResults}&fields=summary,description,status,priority,assignee,labels,comment,created,updated,customfield_10016`
|
|
34
|
+
)
|
|
35
|
+
issues.push(...data.issues)
|
|
36
|
+
startAt += data.issues.length
|
|
37
|
+
if (startAt >= data.total) break
|
|
38
|
+
} while (true)
|
|
39
|
+
|
|
40
|
+
return issues
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const DEFAULT_MAP = {
|
|
44
|
+
validation_status: {
|
|
45
|
+
field: 'fields.status.statusCategory.key',
|
|
46
|
+
map: {
|
|
47
|
+
done: 'validated',
|
|
48
|
+
'in-progress': 'assumed',
|
|
49
|
+
new: 'unvalidated',
|
|
50
|
+
},
|
|
51
|
+
default: 'unvalidated',
|
|
52
|
+
},
|
|
53
|
+
workflow_status: {
|
|
54
|
+
field: 'fields.status.statusCategory.key',
|
|
55
|
+
map: {
|
|
56
|
+
done: 'done',
|
|
57
|
+
'in-progress': 'in_progress',
|
|
58
|
+
new: 'todo',
|
|
59
|
+
},
|
|
60
|
+
default: 'todo',
|
|
61
|
+
},
|
|
62
|
+
cu_tier: {
|
|
63
|
+
field: 'fields.priority.name',
|
|
64
|
+
map: {
|
|
65
|
+
Highest: 'advanced',
|
|
66
|
+
High: 'advanced',
|
|
67
|
+
Medium: 'intermediate',
|
|
68
|
+
Low: 'basic',
|
|
69
|
+
Lowest: 'basic',
|
|
70
|
+
},
|
|
71
|
+
default: null,
|
|
72
|
+
},
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export async function fetchItems(config) {
|
|
76
|
+
const { host, email, apiToken, projectKey } = config
|
|
77
|
+
if (!host) throw new Error('jira.host is required (e.g. yourteam.atlassian.net)')
|
|
78
|
+
if (!email) throw new Error('jira.email is required')
|
|
79
|
+
if (!apiToken) throw new Error('jira.apiToken is required')
|
|
80
|
+
if (!projectKey) throw new Error('jira.projectKey is required')
|
|
81
|
+
|
|
82
|
+
const issues = await fetchAllIssues(config)
|
|
83
|
+
const fieldMap = mergeFieldMap(DEFAULT_MAP, config.fieldMap)
|
|
84
|
+
|
|
85
|
+
return issues.map(issue => {
|
|
86
|
+
const f = issue.fields
|
|
87
|
+
// customfield_10016 is story points in most Jira configs
|
|
88
|
+
const storyPoints = f.customfield_10016
|
|
89
|
+
|
|
90
|
+
// one line per ADF block so heading/checklist detection works downstream
|
|
91
|
+
const descText = f.description?.content
|
|
92
|
+
?.map(b => (b.content ?? []).filter(n => n.type === 'text').map(n => n.text).join(''))
|
|
93
|
+
?.filter(Boolean)
|
|
94
|
+
?.join('\n') ?? ''
|
|
95
|
+
|
|
96
|
+
const labels = f.labels ?? []
|
|
97
|
+
|
|
98
|
+
return {
|
|
99
|
+
id: issue.id,
|
|
100
|
+
external_id: issue.key,
|
|
101
|
+
title: f.summary,
|
|
102
|
+
description: descText,
|
|
103
|
+
url: `https://${host}/browse/${issue.key}`,
|
|
104
|
+
// an explicit validation label beats inferring validation from workflow state
|
|
105
|
+
validation_status: validationFromLabels(labels) ?? applyMap(issue, fieldMap.validation_status),
|
|
106
|
+
workflow_status: applyMap(issue, fieldMap.workflow_status),
|
|
107
|
+
cu_tier: tierFromLabels(labels) ?? applyMap(issue, fieldMap.cu_tier),
|
|
108
|
+
cu_value: storyPoints ?? 1,
|
|
109
|
+
evidence: descText,
|
|
110
|
+
acceptance_criteria: extractAcceptanceCriteria(descText),
|
|
111
|
+
novelty_rating: null,
|
|
112
|
+
complexity_rating: null,
|
|
113
|
+
dependency_rating: null,
|
|
114
|
+
citation_count: f.comment?.total ?? 0,
|
|
115
|
+
assignee: f.assignee?.displayName ?? null,
|
|
116
|
+
labels,
|
|
117
|
+
created_at: f.created,
|
|
118
|
+
updated_at: f.updated,
|
|
119
|
+
source: 'jira',
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
}
|