feat(feishu): add full PR inventory and review attribution
This commit is contained in:
parent
d7812df1af
commit
138d886681
|
|
@ -26,5 +26,14 @@ jobs:
|
|||
- name: Scan i18n key references
|
||||
run: go run ./internal/i18n/cmd/check --scan-code
|
||||
|
||||
- name: Test Feishu shortcuts
|
||||
run: go test ./shortcuts/feishu
|
||||
|
||||
- name: Test workflow shortcuts
|
||||
run: go test ./shortcuts/workflow
|
||||
|
||||
- name: Run Go tests
|
||||
run: go test ./...
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -632,6 +632,14 @@ gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.j
|
|||
# Repository workflow report by read-only GitLink fetch
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --format markdown
|
||||
|
||||
# Optional full PR review attribution. This deep-fetches formal reviews and
|
||||
# PR-associated Issue journals for analyzed PRs, so keep it explicit.
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --include-pr-review-audit --format json > report.review-audit.json
|
||||
|
||||
# Limit analysis only when an intentional sample is needed.
|
||||
# By default, repo-report paginates through all open issues and pull requests.
|
||||
gitlink-cli workflow +repo-report --owner Gitlink --repo gitlink-cli --issue-limit 20 --pr-limit 50 --format markdown
|
||||
|
||||
# Repository workflow report from a local JSON file
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format json
|
||||
```
|
||||
|
|
@ -649,18 +657,26 @@ Safety:
|
|||
- They do not depend on LLM APIs.
|
||||
- `workflow +pr-summary` does not comment, approve, reject, or merge pull requests.
|
||||
- `workflow +repo-report` aggregates health, issue triage, and PR review summary signals without remote writes.
|
||||
- `workflow +repo-report --include-pr-review-audit` remains read-only. It treats formal review objects as authoritative review evidence and keeps submitter, reviewer, participant, and system journal activity separate.
|
||||
|
||||
### Feishu Collaboration Export
|
||||
|
||||
`feishu` turns `workflow +repo-report` JSON into Feishu collaboration outputs.
|
||||
|
||||
`workflow +repo-report` paginates through all open issues and pull requests by
|
||||
default. Feishu cards label these values as analyzed counts. Passing
|
||||
`--issue-limit` or `--pr-limit` intentionally limits the analysis and the
|
||||
resulting values must not be interpreted as repository totals.
|
||||
|
||||
Stable usage:
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --format json > report.json
|
||||
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --include-pr-review-audit --format json > report.review-audit.json
|
||||
|
||||
gitlink-cli feishu +notify --from-workflow-json report.json --format json
|
||||
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
|
||||
gitlink-cli feishu +owner-digest --from-workflow-json report.review-audit.json --format table
|
||||
|
||||
gitlink-cli feishu +weekly-report --from-workflow-json report.json --format markdown
|
||||
gitlink-cli feishu +owner-digest --from-workflow-json report.json --format markdown
|
||||
|
|
|
|||
|
|
@ -555,6 +555,8 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --ye
|
|||
|
||||
`feishu` 将 `workflow +repo-report` JSON 转成飞书协作内容。
|
||||
|
||||
`workflow +repo-report` 默认分页读取并分析全部开放 Issue 和 PR。飞书卡片会把这些值明确标为“已分析数量”。只有显式传 `--issue-limit` 或 `--pr-limit` 时才会采样,此时结果不能解释为仓库总量。
|
||||
|
||||
#### 稳定层:自定义机器人通知
|
||||
|
||||
稳定层只依赖飞书群自定义机器人。它适合把 GitLink 项目状态、周报、Owner 摘要和贡献者摘要推送到群里。默认只预览,真实发送必须显式传 `--send`。
|
||||
|
|
@ -562,6 +564,9 @@ gitlink-cli dataset +delete-attachment --owner me --repo proj --uuid <uuid> --ye
|
|||
```bash
|
||||
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --format json > report.json
|
||||
|
||||
# 仅在明确需要采样时设置上限
|
||||
gitlink-cli workflow +repo-report --owner "$GITLINK_OWNER" --repo "$GITLINK_REPO" --issue-limit 20 --pr-limit 50 --format json > report.sample.json
|
||||
|
||||
gitlink-cli feishu +notify --from-workflow-json report.json --format json
|
||||
gitlink-cli feishu +notify --from-workflow-json report.json --send --format table
|
||||
|
||||
|
|
|
|||
|
|
@ -270,3 +270,33 @@ Authorization policy:
|
|||
GitLink write permissions must be defined by GitLink official maintainers, project owners, and deployers.
|
||||
This module must not hard-code a write-action authorization policy.
|
||||
```
|
||||
|
||||
## Next-Stage Read-Only PR Activity Layer
|
||||
|
||||
Before any GitLink action gateway, a read-only PR activity layer should support:
|
||||
|
||||
```text
|
||||
Complete open/merged/closed inventory.
|
||||
Formal review status.
|
||||
Review and journal attribution by submitter/reviewer/participant/system.
|
||||
Previous-snapshot comparison.
|
||||
Maintainer-role enrichment when authenticated member data is available.
|
||||
Review-content fingerprints and change detection.
|
||||
```
|
||||
|
||||
The generic actor, review, fallback, and snapshot rules are defined in
|
||||
`docs/FEISHU_PR_ACTIVITY_STRATEGY.md`. Member lookup is optional enrichment:
|
||||
when GitLink authentication or permission is unavailable, the CLI must not
|
||||
guess that a participant is a maintainer.
|
||||
|
||||
Current implementation status:
|
||||
|
||||
```text
|
||||
implemented: complete open/merged/closed PR inventory
|
||||
implemented: optional read-only formal review and journal actor attribution
|
||||
implemented: conservative reviewed/unreviewed classification
|
||||
implemented: needs_re_review when submitter activity or PR updates happen after reviewer feedback
|
||||
not implemented: previous-snapshot diff
|
||||
not implemented: maintainer-role enrichment
|
||||
not implemented: review-content fingerprint persistence
|
||||
```
|
||||
|
|
|
|||
|
|
@ -16,6 +16,21 @@ Layer 3: Future callback-based GitLink action gateway
|
|||
|
||||
No implemented command in this branch performs GitLink write operations.
|
||||
|
||||
## GitLink Read Sources Used by Feishu Exports
|
||||
|
||||
The Feishu commands consume `workflow +repo-report` JSON. The workflow report
|
||||
uses these GitLink read-only sources when the corresponding flags are enabled:
|
||||
|
||||
| GitLink read source | Used by | Status | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| `GET /v1/{owner}/{repo}/issues?category=opened` | Issue analysis | Implemented | Paginates all open issues by default |
|
||||
| `GET /v1/{owner}/{repo}/pulls?status=0` | Open PR analysis | Implemented | Paginates all open PRs by default |
|
||||
| `GET /v1/{owner}/{repo}/pulls?status=1` | PR lifecycle totals | Implemented | Merged total only |
|
||||
| `GET /v1/{owner}/{repo}/pulls?status=2` | PR lifecycle totals | Implemented | Closed/rejected total only |
|
||||
| `GET /v1/{owner}/{repo}/pulls/{number}/reviews` | Optional review audit | Implemented read-only | Formal review objects are authoritative review evidence |
|
||||
| `GET /v1/{owner}/{repo}/issues/{issue_id}/journals` | Optional review audit | Implemented read-only | Counts submitter/reviewer/participant/system activity without storing raw comment text |
|
||||
| Repository member lookup | Optional role enrichment | Not implemented in this branch | Maintainer identity is not guessed when auth is unavailable |
|
||||
|
||||
## Source Index
|
||||
|
||||
Official Feishu / Lark references used for this inventory:
|
||||
|
|
@ -503,6 +518,11 @@ Does not require the Feishu module to know a GitLink token.
|
|||
Provides summary-level issue, PR, contributor, recommendation, and health fields.
|
||||
```
|
||||
|
||||
`workflow +repo-report` paginates all open issues and pull requests by default.
|
||||
`--issue-limit` and `--pr-limit` are explicit sampling controls. Counts in
|
||||
Feishu output are labeled as analyzed counts and must not be interpreted as
|
||||
repository totals after an explicit limit is applied.
|
||||
|
||||
Required future source expansion:
|
||||
|
||||
```text
|
||||
|
|
@ -532,6 +552,11 @@ Audit source:
|
|||
future action gateway audit log
|
||||
```
|
||||
|
||||
Next-stage PR activity comparison uses the read-only PR review and associated
|
||||
Issue journal endpoints. Actor attribution and snapshot-diff rules are defined
|
||||
in `docs/FEISHU_PR_ACTIVITY_STRATEGY.md`. This remains planned; current Feishu
|
||||
commands do not crawl or copy PR conversations.
|
||||
|
||||
Reason:
|
||||
|
||||
```text
|
||||
|
|
|
|||
|
|
@ -0,0 +1,246 @@
|
|||
# PR Activity, Review Attribution, and Snapshot Strategy
|
||||
|
||||
Status: next-stage design. No GitLink write operation is implemented here.
|
||||
|
||||
## Goal
|
||||
|
||||
Provide repository owners with a general, cross-repository view of:
|
||||
|
||||
- all current open pull requests;
|
||||
- merged and closed/rejected totals;
|
||||
- state transitions since the previous assessment;
|
||||
- whether a pull request received a formal review;
|
||||
- whether conversation feedback came from the submitter, a reviewer, a
|
||||
maintainer, another participant, a bot, or a system event;
|
||||
- whether review/comment content changed since the previous snapshot.
|
||||
|
||||
The model must not depend on a specific repository, user login, PR number, or
|
||||
organization role name.
|
||||
|
||||
## Verified Read Sources
|
||||
|
||||
The GitLink API surfaces needed by this design are read-only:
|
||||
|
||||
```text
|
||||
GET /v1/{owner}/{repo}/pulls
|
||||
GET /v1/{owner}/{repo}/pulls/{number}
|
||||
GET /v1/{owner}/{repo}/pulls/{number}/reviews
|
||||
GET /v1/{owner}/{repo}/issues/{issue_id}/journals
|
||||
GET repository members when the current identity has permission
|
||||
```
|
||||
|
||||
Local validation confirmed:
|
||||
|
||||
- list responses expose PR state, author, associated issue ID, and pagination
|
||||
totals;
|
||||
- formal reviews expose reviewer identity, status, content, and time;
|
||||
- journals expose actor identity, comment text, state events, created time, and
|
||||
updated time;
|
||||
- repository-member lookup may return 401 for an unauthenticated read. The
|
||||
implementation must degrade to `participant`, not guess maintainer status.
|
||||
|
||||
## Actor Classification
|
||||
|
||||
Normalize identities by stable user ID first and login second.
|
||||
|
||||
| Actor class | Evidence |
|
||||
| --- | --- |
|
||||
| `submitter` | Actor matches the PR author |
|
||||
| `reviewer` | Actor owns a formal review or is in the assigned reviewer set |
|
||||
| `maintainer` | Repository membership data proves a configured privileged role |
|
||||
| `participant` | Authenticated human who is none of the above |
|
||||
| `bot` | Explicit bot/application identity |
|
||||
| `system` | State transition or generated event without human review content |
|
||||
| `unknown` | Identity is incomplete |
|
||||
|
||||
Role precedence:
|
||||
|
||||
```text
|
||||
system/bot -> submitter -> formal reviewer -> maintainer -> participant -> unknown
|
||||
```
|
||||
|
||||
A user can be both maintainer and reviewer. Event attribution records the most
|
||||
specific event relationship (`reviewer`) and may retain `is_maintainer=true` as
|
||||
an additional property.
|
||||
|
||||
## Review Standard
|
||||
|
||||
Do not treat every comment as a review.
|
||||
|
||||
### Authoritative review
|
||||
|
||||
A formal review object with:
|
||||
|
||||
```text
|
||||
status: approved | rejected | common
|
||||
reviewer identity
|
||||
created_at
|
||||
```
|
||||
|
||||
is authoritative review evidence.
|
||||
|
||||
### Review-like journal feedback
|
||||
|
||||
A journal comment is review feedback only when:
|
||||
|
||||
1. it has non-empty human-authored content;
|
||||
2. it is not a creation/status/system event;
|
||||
3. the actor is a formal/assigned reviewer or a proven maintainer;
|
||||
4. the actor is not the PR submitter, unless the UI explicitly marks a
|
||||
self-review;
|
||||
5. the normalized content is not only an acknowledgement such as `LGTM`,
|
||||
`thanks`, or a generated status line, unless the product policy explicitly
|
||||
enables acknowledgement reviews.
|
||||
|
||||
When member data is unavailable, a comment from an unassigned actor remains
|
||||
`participant_feedback`, not `maintainer_review`.
|
||||
|
||||
## Risk Is Separate From Review
|
||||
|
||||
Current `workflow +repo-report` risk is rule-based. A list-metadata keyword hit
|
||||
is a risk hint, not proof that a reviewer found a problem.
|
||||
|
||||
The next-stage output should keep separate fields:
|
||||
|
||||
```text
|
||||
metadata_risk_hint
|
||||
code_change_risk
|
||||
formal_review_status
|
||||
review_feedback_status
|
||||
merge_readiness
|
||||
```
|
||||
|
||||
Detailed code risk requires files and commits. Bulk list metadata alone must
|
||||
not be presented as a formal review conclusion.
|
||||
|
||||
## Snapshot Model
|
||||
|
||||
Recommended local snapshot:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"repository": "owner/repo",
|
||||
"generated_at": "RFC3339",
|
||||
"totals": {
|
||||
"open": 0,
|
||||
"merged": 0,
|
||||
"closed": 0
|
||||
},
|
||||
"pull_requests": [
|
||||
{
|
||||
"number": 1,
|
||||
"state": "open",
|
||||
"author_id": "stable-id",
|
||||
"updated_at": "RFC3339",
|
||||
"head_revision": "optional",
|
||||
"formal_review_status": "unreviewed",
|
||||
"review_fingerprint": "sha256",
|
||||
"conversation_fingerprint": "sha256",
|
||||
"events": []
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Raw access tokens and private profile fields must never enter snapshots.
|
||||
|
||||
## Content Fingerprints
|
||||
|
||||
Normalize review/comment content before hashing:
|
||||
|
||||
1. normalize line endings;
|
||||
2. trim leading/trailing whitespace;
|
||||
3. collapse repeated whitespace outside code blocks;
|
||||
4. remove generated status-only markup;
|
||||
5. preserve code and semantic text;
|
||||
6. hash actor ID, event type, normalized content, and event time.
|
||||
|
||||
Store hashes and bounded summaries by default. Raw comment content should be
|
||||
included only in an explicitly local evidence file.
|
||||
|
||||
## Snapshot Diff
|
||||
|
||||
Compare the current snapshot with `--previous-snapshot` and emit:
|
||||
|
||||
```text
|
||||
new_open_prs
|
||||
newly_merged_prs
|
||||
newly_closed_prs
|
||||
reopened_prs
|
||||
new_formal_reviews
|
||||
review_status_changes
|
||||
new_reviewer_feedback
|
||||
edited_reviewer_feedback
|
||||
submitter_responses
|
||||
participant_feedback
|
||||
```
|
||||
|
||||
State transitions are determined by PR number plus previous/current state, not
|
||||
by subtracting aggregate totals.
|
||||
|
||||
## Fetch Strategy
|
||||
|
||||
Default inventory:
|
||||
|
||||
1. paginate all PR list states;
|
||||
2. record exact list totals and basic identity/state fields;
|
||||
3. compare with the previous snapshot;
|
||||
4. deep-fetch reviews and journals only for new or updated PRs.
|
||||
|
||||
Optional full audit:
|
||||
|
||||
```text
|
||||
--full-review-audit
|
||||
```
|
||||
|
||||
This explicitly deep-fetches every PR and may require hundreds of API calls.
|
||||
Use bounded concurrency, retry/backoff, and a request summary. It must remain
|
||||
read-only.
|
||||
|
||||
## Planned Commands
|
||||
|
||||
```text
|
||||
gitlink-cli workflow +pr-activity-snapshot
|
||||
gitlink-cli workflow +pr-activity-diff
|
||||
gitlink-cli feishu +owner-activity-digest
|
||||
```
|
||||
|
||||
The Feishu digest should show aggregate transitions and the most important
|
||||
changed PRs. It must link to GitLink for full comments rather than copying an
|
||||
unbounded conversation into a card.
|
||||
|
||||
## Current Boundary
|
||||
|
||||
Implemented in the current branch:
|
||||
|
||||
- correct GitLink Issue and PR list filters;
|
||||
- complete pagination for `workflow +repo-report` by default;
|
||||
- PR lifecycle totals for open, merged, and closed/rejected states;
|
||||
- explicit analyzed-count labels and scope notes;
|
||||
- optional read-only formal review and journal actor attribution through
|
||||
`workflow +repo-report --include-pr-review-audit`.
|
||||
|
||||
The implemented audit follows the conservative review standard in this
|
||||
document:
|
||||
|
||||
- a formal `/pulls/{number}/reviews` object marks the PR as reviewed;
|
||||
- journal comments from the PR submitter are counted as submitter responses,
|
||||
not reviews;
|
||||
- journal comments from an actor who also has a formal review on the PR are
|
||||
counted as reviewer feedback;
|
||||
- other human comments are counted as participant feedback;
|
||||
- status changes and empty/generated events are counted as system events;
|
||||
- a reviewed PR is marked `needs_re_review` when a later submitter comment,
|
||||
later commit, or later PR update timestamp is newer than the latest reviewer
|
||||
feedback timestamp;
|
||||
- maintainer classification is not guessed when repository-member data is not
|
||||
available.
|
||||
|
||||
Not implemented in the current branch:
|
||||
|
||||
- snapshot persistence;
|
||||
- member-role enrichment;
|
||||
- review-content diffing;
|
||||
- Feishu activity-diff cards;
|
||||
- any GitLink write operation.
|
||||
229
docs/pr-draft.md
229
docs/pr-draft.md
|
|
@ -1,105 +1,184 @@
|
|||
# feat(workflow): add agent workflow commands for repository maintenance
|
||||
# feat(feishu): add layered Feishu collaboration exports for workflow reports
|
||||
|
||||
## Summary
|
||||
|
||||
This PR adds four read-only workflow commands for repository maintenance:
|
||||
- Add Feishu custom-bot cards for GitLink workflow reports.
|
||||
- Add weekly, owner, and contributor digests.
|
||||
- Add Bitable-ready schemas and records.
|
||||
- Add experimental DocX, Bitable, and Task OpenAPI writes.
|
||||
- Add read-only Open Platform readiness diagnostics.
|
||||
- Add English and zh-CN Feishu output.
|
||||
- Fix workflow Issue/PR list filters and paginate all open items by default.
|
||||
- Add optional read-only PR review audit for formal reviews and comment actor attribution.
|
||||
- Keep all GitLink write operations out of scope.
|
||||
|
||||
- `workflow +triage`
|
||||
- `workflow +health`
|
||||
- `workflow +pr-summary`
|
||||
- `workflow +repo-report`
|
||||
## Data Correctness
|
||||
|
||||
The commands provide rule-based, explainable analysis with stable `json`, concise `table`,
|
||||
and copy-friendly `markdown` output.
|
||||
`workflow +repo-report` now uses the GitLink API parameters used by the native
|
||||
Issue and PR commands:
|
||||
|
||||
## Motivation
|
||||
```text
|
||||
Issue open filter: category=opened
|
||||
PR open filter: status=0
|
||||
```
|
||||
|
||||
Open-source maintainers often spend time on repetitive information organization before
|
||||
making actual decisions:
|
||||
It paginates all open issues and pull requests by default. An explicit
|
||||
`--issue-limit` or `--pr-limit` enables sampling.
|
||||
|
||||
- Issue triage cost
|
||||
- PR review cost
|
||||
- repository health visibility
|
||||
- Agent needs stable structured output
|
||||
Feishu output labels these values as analyzed counts and includes a scope note.
|
||||
This avoids presenting a limited sample as a repository total.
|
||||
|
||||
This PR adds workflow-level analysis on top of the existing GitLink CLI shortcut architecture
|
||||
without introducing LLM dependencies or remote write behavior.
|
||||
## PR Review Attribution
|
||||
|
||||
## Changes
|
||||
`workflow +repo-report --include-pr-review-audit` performs a read-only audit of
|
||||
the analyzed PRs:
|
||||
|
||||
### `workflow +triage`
|
||||
```text
|
||||
formal /pulls/{number}/reviews objects mark a PR as reviewed
|
||||
submitter comments are counted separately and do not mark a PR as reviewed
|
||||
participant comments are counted separately and do not mark a PR as reviewed
|
||||
comments from actors with formal review identity are counted as reviewer feedback
|
||||
maintainer role is not guessed when member lookup is unavailable
|
||||
```
|
||||
|
||||
- Classifies issues by type
|
||||
- Scores priority and confidence
|
||||
- Detects missing bug-report information
|
||||
- Produces risk flags, recommended actions, suggested comments, and reasoning
|
||||
This keeps metadata risk, formal review status, and conversation attribution as
|
||||
separate signals.
|
||||
|
||||
### `workflow +health`
|
||||
## Stable Surface
|
||||
|
||||
- Scores repository health
|
||||
- Covers issue/PR backlog, activity, release, CI, docs, license, contributing, and Agent readiness signals
|
||||
- Tolerates unknown metrics without failing the command
|
||||
```text
|
||||
feishu +bot-test
|
||||
feishu +notify
|
||||
feishu +weekly-report
|
||||
feishu +owner-digest
|
||||
feishu +contributor-digest
|
||||
feishu +bitable-schema
|
||||
feishu +bitable-records
|
||||
feishu +task-preview
|
||||
```
|
||||
|
||||
### `workflow +pr-summary`
|
||||
Stable commands preview locally by default. Custom-bot delivery requires
|
||||
explicit `--send`.
|
||||
|
||||
- Summarizes PR metadata, changed files, and commits
|
||||
- Produces change type, risk level, review focus, test suggestions, merge checklist, and reasoning
|
||||
- Supports local JSON input and remote read-only PR fetch
|
||||
## Readiness Diagnostics
|
||||
|
||||
### `workflow +repo-report`
|
||||
```text
|
||||
feishu +app-check
|
||||
feishu +doc-check
|
||||
feishu +bitable-check
|
||||
feishu +task-check
|
||||
```
|
||||
|
||||
- Aggregates health, issue triage, and PR summary signals
|
||||
- Produces a repository workflow report with score, risk level, recommendations, and reasoning
|
||||
- Supports partial read-only remote aggregation when optional sections are unavailable
|
||||
Local mode checks configuration only. `--remote` performs read/check OpenAPI
|
||||
calls and does not create or modify Feishu or GitLink resources.
|
||||
|
||||
## Experimental Surface
|
||||
|
||||
```text
|
||||
feishu +doc-export
|
||||
feishu +bitable-sync
|
||||
feishu +task-create
|
||||
```
|
||||
|
||||
These commands require a Feishu self-built app and explicit `--send`.
|
||||
|
||||
## Safety
|
||||
|
||||
- Remote mode is read-only
|
||||
- No LLM dependency
|
||||
- No labels/comments/close operations
|
||||
- No PR approve/reject/merge operations
|
||||
- No `internal/output` change
|
||||
- No new third-party dependency
|
||||
- Test fixtures do not contain secrets or tokens
|
||||
- Preview/check by default.
|
||||
- Real Feishu side effects require explicit `--send`.
|
||||
- Remote readiness calls require explicit `--remote`.
|
||||
- GitLink write operations are not implemented.
|
||||
- Card buttons are navigation-only.
|
||||
- Secrets and resource IDs come from ignored local env files.
|
||||
- CLI and smoke output redact sensitive values.
|
||||
- Bitable sync never deletes records.
|
||||
|
||||
## Tests
|
||||
## Real Validation
|
||||
|
||||
```bash
|
||||
gofmt -w shortcuts/workflow/*.go shortcuts/register.go
|
||||
The local test enterprise validated:
|
||||
|
||||
```text
|
||||
custom bot card delivery
|
||||
English and zh-CN cards
|
||||
DocX append
|
||||
Bitable search/create/update
|
||||
Feishu Task create
|
||||
app/doc/bitable/task readiness diagnostics
|
||||
```
|
||||
|
||||
The current real repository report validated complete default pagination:
|
||||
|
||||
```text
|
||||
open issues analyzed: 9
|
||||
open pull requests analyzed: 166
|
||||
open/merged/closed PR lifecycle totals: 166 / 65 / 74
|
||||
full review-audit result: 166 audited, 4 reviewed, 162 unreviewed
|
||||
needs re-review after reviewer feedback: 0
|
||||
```
|
||||
|
||||
Task creation was not repeated during the final smoke because Feishu-side
|
||||
deduplication is not implemented.
|
||||
|
||||
## Validation Commands
|
||||
|
||||
```powershell
|
||||
go run . workflow +repo-report --owner "$env:GITLINK_OWNER" --repo "$env:GITLINK_REPO" --format json > .local\report.json
|
||||
go run . workflow +repo-report --owner "$env:GITLINK_OWNER" --repo "$env:GITLINK_REPO" --include-pr-review-audit --format json > .local\report.review-audit.full.json
|
||||
|
||||
go run . feishu +app-check --remote --format table
|
||||
go run . feishu +doc-check --remote --format table
|
||||
go run . feishu +bitable-check --tables reports,issues,prs,tasks --remote --format table
|
||||
go run . feishu +task-check --remote --format table
|
||||
|
||||
go run . feishu +notify --from-workflow-json .local\report.json --send --format table
|
||||
go run . feishu +owner-digest --from-workflow-json .local\report.review-audit.full.json --send --format table
|
||||
go run . feishu +doc-export --from-workflow-json .local\report.json --send --format table
|
||||
go run . feishu +bitable-sync --from-workflow-json .local\report.json --send --format table
|
||||
|
||||
go test ./shortcuts/feishu
|
||||
go test ./shortcuts/workflow
|
||||
go test ./...
|
||||
go vet ./...
|
||||
```
|
||||
|
||||
Coverage includes:
|
||||
## Review and Comment Attribution Boundary
|
||||
|
||||
- triage rules
|
||||
- health scoring
|
||||
- PR summary rules
|
||||
- repo report aggregation
|
||||
- fetch normalization
|
||||
- partial failure handling
|
||||
- `json` / `table` / `markdown` rendering
|
||||
- local `--from` fixtures
|
||||
- command wiring tests
|
||||
Formal reviews and PR-associated Issue journals are consumed only by the
|
||||
optional read-only audit path. Previous-snapshot comparison, member-role
|
||||
enrichment, and review-content fingerprint persistence remain designed in:
|
||||
|
||||
## Documentation
|
||||
|
||||
- `README.md`
|
||||
- `docs/workflow-agent-design.md`
|
||||
- `docs/workflow-agent-test-report.md`
|
||||
- `skills/gitlink-workflow/SKILL.md`
|
||||
|
||||
## Known Limitations
|
||||
|
||||
- `workflow +release-notes` is not implemented.
|
||||
- `workflow +stale` is not implemented.
|
||||
- Real GitLink API shapes may require follow-up normalization.
|
||||
|
||||
## Examples
|
||||
|
||||
```bash
|
||||
gitlink-cli workflow +triage --from shortcuts/workflow/testdata/issue_bug.json --format table
|
||||
gitlink-cli workflow +health --from shortcuts/workflow/testdata/health_good.json --format markdown
|
||||
gitlink-cli workflow +pr-summary --from shortcuts/workflow/testdata/pr_summary.json --format markdown
|
||||
gitlink-cli workflow +repo-report --from shortcuts/workflow/testdata/repo_report.json --format markdown
|
||||
```text
|
||||
docs/FEISHU_PR_ACTIVITY_STRATEGY.md
|
||||
```
|
||||
|
||||
## Evidence
|
||||
|
||||
```text
|
||||
reports/FEISHU_SMOKE_20260626.md
|
||||
reports/FEISHU_SMOKE_20260627.md
|
||||
reports/FEISHU_SMOKE_EVIDENCE_20260627.md
|
||||
reports/FEISHU_PERMISSION_MATRIX.md
|
||||
docs/FEISHU_OPENAPI_INVENTORY.md
|
||||
```
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- GitLink issue comment or close.
|
||||
- GitLink PR review, approve, reject, or merge.
|
||||
- GitLink member management.
|
||||
- Feishu callback server.
|
||||
- Feishu-to-GitLink identity mapping.
|
||||
- Automatic Base/table/field/view creation.
|
||||
- Task project/section/assignee placement.
|
||||
- Feishu-side Task deduplication.
|
||||
- PR activity snapshot persistence.
|
||||
- Review-content fingerprint diffing.
|
||||
- Maintainer-role enrichment without authenticated member data.
|
||||
|
||||
## Reviewer Questions
|
||||
|
||||
- Should webhook export remain the stable main path?
|
||||
- Should DocX, Bitable, and Task writes remain experimental?
|
||||
- Should full PR review activity be a separate workflow command?
|
||||
- Should member-role enrichment require authenticated GitLink access?
|
||||
- Should future Feishu callbacks live in gitlink-cli or a separate service?
|
||||
|
|
|
|||
|
|
@ -0,0 +1,242 @@
|
|||
# Feishu Smoke Report
|
||||
|
||||
Date: 2026-06-27
|
||||
|
||||
## Branch and Base Commit
|
||||
|
||||
```text
|
||||
branch: feat/feishu-export-clean
|
||||
base commit: d7812df1af49519f9eb84def218bd3d5a9fdf02f
|
||||
```
|
||||
|
||||
This smoke run included uncommitted data-correctness fixes that are documented
|
||||
below and will receive a new commit after final validation.
|
||||
|
||||
## Environment
|
||||
|
||||
```text
|
||||
Real Feishu test enterprise: used
|
||||
Real GitLink repository: used
|
||||
Custom bot: used
|
||||
Self-built Feishu app: used
|
||||
DocX target: used
|
||||
Five split Bitable tables: used
|
||||
GitLink write operations: not used
|
||||
```
|
||||
|
||||
Real credentials and resource IDs remained in the ignored file:
|
||||
|
||||
```text
|
||||
.local/feishu-gitlink.env.ps1
|
||||
```
|
||||
|
||||
## Readiness Diagnostics
|
||||
|
||||
| Command | Result | Side effect |
|
||||
| --- | --- | --- |
|
||||
| `feishu +app-check` | pass | none |
|
||||
| `feishu +doc-check` | pass | none |
|
||||
| `feishu +bitable-check --tables reports,issues,prs,tasks` | pass | none |
|
||||
| `feishu +task-check` | pass with expected project/section/dedupe warnings | none |
|
||||
| `feishu +app-check --remote` | pass | tenant token check only |
|
||||
| `feishu +doc-check --remote` | pass with write-permission checks skipped | Wiki/DocX read/check only |
|
||||
| `feishu +bitable-check --remote` | pass for four tables | sentinel search only |
|
||||
| `feishu +task-check --remote` | pass with expected warnings | tenant token check only |
|
||||
|
||||
## Data-Correctness Finding
|
||||
|
||||
The first report generated:
|
||||
|
||||
```text
|
||||
issues analyzed: 19
|
||||
pull requests analyzed: 10
|
||||
```
|
||||
|
||||
The GitLink UI showed:
|
||||
|
||||
```text
|
||||
open issues: 9
|
||||
open pull requests: 166
|
||||
```
|
||||
|
||||
The values were real API-derived values, but the workflow request semantics
|
||||
were wrong:
|
||||
|
||||
1. Issue workflow fetch sent `state=open`. GitLink Issue list requires
|
||||
`category=opened`, so the API ignored the filter and returned 9 open plus 10
|
||||
closed issues.
|
||||
2. PR workflow fetch sent `state=open`. GitLink PR list requires `status=0`, so
|
||||
the API ignored the filter and returned all states.
|
||||
3. The old repo-report defaults analyzed only 20 issues and 10 PRs.
|
||||
4. The API can cap a requested page at 50 records, so stopping only because a
|
||||
page is shorter than the requested limit can truncate a report.
|
||||
|
||||
## Data-Correctness Fix
|
||||
|
||||
The branch now:
|
||||
|
||||
```text
|
||||
uses category=opened for open Issue queries
|
||||
uses status=0 for open PR queries
|
||||
paginates until the API total_count is reached
|
||||
deduplicates list items by stable identifiers
|
||||
recognizes GitLink PR index as the user-facing PR number
|
||||
analyzes all open issues and PRs by default
|
||||
uses --issue-limit/--pr-limit only as explicit sampling controls
|
||||
labels Feishu counts as analyzed counts
|
||||
adds a scope note that sampled values are not repository totals
|
||||
```
|
||||
|
||||
Real post-fix result:
|
||||
|
||||
```text
|
||||
open issues analyzed: 9
|
||||
open pull requests analyzed: 166
|
||||
open PR lifecycle total: 166
|
||||
merged PR lifecycle total: 65
|
||||
closed/rejected PR lifecycle total: 74
|
||||
```
|
||||
|
||||
These values match the GitLink web UI badges used during the smoke run.
|
||||
|
||||
## PR Risk Source
|
||||
|
||||
The bulk repo report uses PR list metadata, not changed files, commits, reviews,
|
||||
or journal comments. The 13 critical metadata classifications came from the
|
||||
existing `security-sensitive keyword` rule:
|
||||
|
||||
| PR | Metadata hit |
|
||||
| --- | --- |
|
||||
| 76 | token |
|
||||
| 77 | token |
|
||||
| 115 | token |
|
||||
| 146 | token |
|
||||
| 167 | secret |
|
||||
| 171 | token, secret, credential |
|
||||
| 173 | secret |
|
||||
| 183 | secret |
|
||||
| 189 | token |
|
||||
| 225 | token |
|
||||
| 254 | token |
|
||||
| 280 | token |
|
||||
| 293 | token |
|
||||
|
||||
This is a metadata risk hint, not a formal reviewer conclusion. Detailed code
|
||||
risk requires files and commits. Formal review status must be reported
|
||||
separately.
|
||||
|
||||
## Review and Journal API Validation
|
||||
|
||||
Three historical PRs were used only as local read-only samples:
|
||||
|
||||
| Sample | Formal review | Journal result |
|
||||
| --- | --- | --- |
|
||||
| PR 95 | one approved review with reviewer identity and content | review comments and merged event readable |
|
||||
| PR 29 | one approved review with reviewer identity and content | review comment and merged event readable |
|
||||
| PR 75 | no formal review object | review-like comments and rejected/closed event readable |
|
||||
|
||||
The repository member list returned 401 without GitLink authentication.
|
||||
Therefore a generic implementation must not guess maintainer identity. It can
|
||||
still reliably distinguish the submitter, formal reviewer, participant, and
|
||||
system event. The cross-repository strategy is documented in:
|
||||
|
||||
```text
|
||||
docs/FEISHU_PR_ACTIVITY_STRATEGY.md
|
||||
```
|
||||
|
||||
## Full PR Review Audit
|
||||
|
||||
After the strategy was implemented as an explicit read-only audit path, the
|
||||
full open-PR inventory was audited with:
|
||||
|
||||
```text
|
||||
workflow +repo-report --include-pr-review-audit
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
```text
|
||||
PRs analyzed: 166
|
||||
PRs review-audited: 166
|
||||
PRs with formal review evidence: 4
|
||||
PRs without formal review evidence: 162
|
||||
PRs needing re-review after reviewer feedback: 0
|
||||
formal reviews: 4
|
||||
reviewer comments: 6
|
||||
submitter comments: 0
|
||||
participant comments: 436
|
||||
system events: 0
|
||||
audit errors: 0
|
||||
```
|
||||
|
||||
Review judgment is conservative:
|
||||
|
||||
```text
|
||||
formal /pulls/{number}/reviews objects mark a PR as reviewed
|
||||
submitter comments do not mark a PR as reviewed
|
||||
participant comments do not mark a PR as reviewed
|
||||
comments by actors with formal review identity are counted as reviewer feedback
|
||||
reviewed PRs are marked needs_re_review when later submitter comments, later commits, or later PR updates appear after the last reviewer feedback
|
||||
maintainer identity is not guessed without member-role data
|
||||
```
|
||||
|
||||
## Real Feishu Writes
|
||||
|
||||
| Command group | Result |
|
||||
| --- | --- |
|
||||
| Eight webhook test/report/digest sends | HTTP 200, Feishu code 0 |
|
||||
| Corrected full-analysis notify card | HTTP 200, Feishu code 0 |
|
||||
| Corrected full-analysis owner digest | HTTP 200, Feishu code 0 |
|
||||
| Full review-audit notify card | HTTP 200, Feishu code 0 |
|
||||
| Full review-audit owner digest | HTTP 200, Feishu code 0 |
|
||||
| Final English smoke notify card | HTTP 200, Feishu code 0 |
|
||||
| Final English smoke owner digest | HTTP 200, Feishu code 0 |
|
||||
| English and Chinese DocX append | 9 blocks each |
|
||||
| Corrected Chinese DocX append | 11 blocks |
|
||||
| Bitable full-analysis upsert | reports 1 updated; issues 5 updated; PRs 5 created/3 updated; contributors 1 updated; tasks 2 created/7 updated |
|
||||
| Task create | intentionally skipped in this run to avoid duplicates |
|
||||
|
||||
## Current Boundaries
|
||||
|
||||
```text
|
||||
No GitLink write operation.
|
||||
No callback server.
|
||||
No automatic Base/table/view creation.
|
||||
No Feishu-side Task dedupe.
|
||||
No PR activity snapshot persistence.
|
||||
No previous-snapshot review diff.
|
||||
No review-content fingerprint persistence.
|
||||
No maintainer-role guess when member lookup is unavailable.
|
||||
```
|
||||
|
||||
## Test Results
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| `go test ./shortcuts/feishu` | pass |
|
||||
| `go test ./shortcuts/workflow` | pass |
|
||||
| `go test ./shortcuts` | pass |
|
||||
| `go test ./...` | pass |
|
||||
| `go build .` | pass |
|
||||
| `go vet ./...` | pass |
|
||||
| `go run ./internal/i18n/cmd/check` | blocked by existing Windows locale line-ending issue |
|
||||
| `go run ./internal/i18n/cmd/check --scan-code` | blocked by the same formatting check |
|
||||
|
||||
The i18n line-ending and missing-key fix remains in its independent branch/PR
|
||||
and is intentionally not duplicated into this Feishu change.
|
||||
|
||||
## Screenshot Status
|
||||
|
||||
The requested Windows computer-use connection failed twice during plugin
|
||||
initialization:
|
||||
|
||||
```text
|
||||
failed to write kernel assets: path not found
|
||||
```
|
||||
|
||||
No screenshot was fabricated or committed. Text evidence and API-derived
|
||||
results remain the evidence for this run. See:
|
||||
|
||||
```text
|
||||
reports/FEISHU_SMOKE_EVIDENCE_20260627.md
|
||||
```
|
||||
|
|
@ -0,0 +1,61 @@
|
|||
# Feishu Smoke Evidence
|
||||
|
||||
Date: 2026-06-27
|
||||
|
||||
Branch:
|
||||
|
||||
```text
|
||||
feat/feishu-export-clean
|
||||
```
|
||||
|
||||
Base commit:
|
||||
|
||||
```text
|
||||
d7812df1af49519f9eb84def218bd3d5a9fdf02f
|
||||
```
|
||||
|
||||
## Evidence Files
|
||||
|
||||
| Evidence | Expected file | Status | Notes |
|
||||
| --- | --- | --- | --- |
|
||||
| Custom bot notify card | `reports/images/feishu-card-notify-redacted.png` | not captured | computer-use initialization failed |
|
||||
| Owner digest card | `reports/images/feishu-owner-digest-redacted.png` | not captured | corrected card was sent successfully |
|
||||
| DocX append result | `reports/images/feishu-docx-append-redacted.png` | not captured | corrected 11-block append passed |
|
||||
| Bitable sync result | `reports/images/feishu-bitable-sync-redacted.png` | not captured | real upsert passed |
|
||||
| Task create result | `reports/images/feishu-task-create-redacted.png` | not captured | historical result retained; creation was not repeated |
|
||||
| Diagnostics output | `reports/images/feishu-diagnostics-terminal-redacted.png` | not captured | local and remote checks passed |
|
||||
|
||||
No placeholder or fabricated image file is committed.
|
||||
|
||||
## Text Evidence
|
||||
|
||||
```text
|
||||
reports/FEISHU_SMOKE_20260626.md
|
||||
reports/FEISHU_SMOKE_20260627.md
|
||||
reports/FEISHU_PERMISSION_MATRIX.md
|
||||
reports/FEISHU_API_COLLECTION_CHECKLIST_20260626.md
|
||||
docs/FEISHU_OPENAPI_INVENTORY.md
|
||||
docs/FEISHU_PR_ACTIVITY_STRATEGY.md
|
||||
```
|
||||
|
||||
## Redaction Checklist
|
||||
|
||||
```text
|
||||
[x] No webhook URL committed
|
||||
[x] No app secret committed
|
||||
[x] No tenant_access_token committed
|
||||
[x] No document token committed
|
||||
[x] No Base app token committed
|
||||
[x] No table ID committed
|
||||
[x] No task ID committed
|
||||
[x] No open_id / union_id committed
|
||||
[x] No personal account credential committed
|
||||
[x] No unredacted screenshot committed
|
||||
```
|
||||
|
||||
## Capture Rule
|
||||
|
||||
Screenshots may be added only after the Windows automation connection works and
|
||||
each image is reviewed for resource IDs, personal identities, and unrelated
|
||||
conversation content. Until then, this document records the missing visual
|
||||
evidence explicitly rather than presenting a fake pass.
|
||||
|
|
@ -58,19 +58,41 @@ func BuildWorkflowCard(report workflow.RepoReportResult, include []string, title
|
|||
}
|
||||
if hasItem(include, "issues") {
|
||||
elements = append(elements, fields([]fieldValue{
|
||||
{Label: feishuLabel(lang, "issues"), Value: fmt.Sprintf("%d", report.IssueSummary.Total)},
|
||||
{Label: feishuLabel(lang, "issues_analyzed"), Value: fmt.Sprintf("%d", report.IssueSummary.Total)},
|
||||
{Label: feishuLabel(lang, "high_risk_issues"), Value: fmt.Sprintf("%d", report.IssueSummary.HighRisk)},
|
||||
{Label: feishuLabel(lang, "missing_info"), Value: fmt.Sprintf("%d", report.IssueSummary.MissingInfo)},
|
||||
}))
|
||||
}
|
||||
if hasItem(include, "prs") {
|
||||
elements = append(elements, fields([]fieldValue{
|
||||
{Label: feishuLabel(lang, "pull_requests"), Value: fmt.Sprintf("%d", report.PRSummary.Total)},
|
||||
{Label: feishuLabel(lang, "prs_analyzed"), Value: fmt.Sprintf("%d", report.PRSummary.Total)},
|
||||
{Label: feishuLabel(lang, "high_risk_prs"), Value: fmt.Sprintf("%d", report.PRSummary.HighRisk)},
|
||||
}))
|
||||
if report.PRLifecycle != nil {
|
||||
elements = append(elements, fields([]fieldValue{
|
||||
{Label: feishuLabel(lang, "open_prs"), Value: fmt.Sprintf("%d", report.PRLifecycle.Open)},
|
||||
{Label: feishuLabel(lang, "merged_prs"), Value: fmt.Sprintf("%d", report.PRLifecycle.Merged)},
|
||||
{Label: feishuLabel(lang, "closed_prs"), Value: fmt.Sprintf("%d", report.PRLifecycle.ClosedOrRejected)},
|
||||
}))
|
||||
}
|
||||
if report.PRReviewAudit != nil {
|
||||
elements = append(elements, fields([]fieldValue{
|
||||
{Label: feishuLabel(lang, "review_audited"), Value: fmt.Sprintf("%d", report.PRReviewAudit.Audited)},
|
||||
{Label: feishuLabel(lang, "reviewed_prs"), Value: fmt.Sprintf("%d", report.PRReviewAudit.Reviewed)},
|
||||
{Label: feishuLabel(lang, "unreviewed_prs"), Value: fmt.Sprintf("%d", report.PRReviewAudit.Unreviewed)},
|
||||
{Label: feishuLabel(lang, "needs_re_review"), Value: fmt.Sprintf("%d", report.PRReviewAudit.NeedsReReview)},
|
||||
{Label: feishuLabel(lang, "formal_reviews"), Value: fmt.Sprintf("%d", report.PRReviewAudit.FormalReviews)},
|
||||
}))
|
||||
elements = append(elements, div(fmt.Sprintf("**%s**\n%s",
|
||||
feishuLabel(lang, "review_actor_attribution"),
|
||||
bulletList(reviewAuditActorLines(report.PRReviewAudit, lang), 6))))
|
||||
}
|
||||
if len(report.PRSummary.ReviewFocus) > 0 {
|
||||
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "review_focus"), bulletList(localizeFeishuLines(report.PRSummary.ReviewFocus, lang), 4))))
|
||||
}
|
||||
if lines := riskSourceLines(report.PRSummary.RiskSources); len(lines) > 0 {
|
||||
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "risk_sources"), bulletList(lines, 8))))
|
||||
}
|
||||
}
|
||||
if len(report.Recommendations) > 0 {
|
||||
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "recommendations"), bulletList(localizeFeishuLines(report.Recommendations, lang), 5))))
|
||||
|
|
@ -78,6 +100,7 @@ func BuildWorkflowCard(report workflow.RepoReportResult, include []string, title
|
|||
if strings.TrimSpace(docURL) != "" {
|
||||
elements = append(elements, actionButton(feishuLabel(lang, "open_feishu_report"), docURL))
|
||||
}
|
||||
elements = append(elements, note(feishuLabel(lang, "analysis_scope")))
|
||||
elements = append(elements, note(feishuLabel(lang, "preview_note")))
|
||||
return baseCard(title, templateForRisk(report.RiskLevel), elements)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ package feishu
|
|||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"sort"
|
||||
"strings"
|
||||
"text/tabwriter"
|
||||
|
||||
|
|
@ -10,24 +11,27 @@ import (
|
|||
)
|
||||
|
||||
type RoleDigest struct {
|
||||
Role string `json:"role"`
|
||||
Repository string `json:"repository"`
|
||||
RepositoryURL string `json:"repository_url,omitempty"`
|
||||
DocURL string `json:"doc_url,omitempty"`
|
||||
HealthScore *int `json:"health_score,omitempty"`
|
||||
HealthRisk string `json:"health_risk,omitempty"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ReportScore int `json:"report_score"`
|
||||
IssueTotal int `json:"issue_total"`
|
||||
IssueHighRisk int `json:"issue_high_risk"`
|
||||
IssueMissingInfo int `json:"issue_missing_info"`
|
||||
PRTotal int `json:"pr_total"`
|
||||
PRHighRisk int `json:"pr_high_risk"`
|
||||
ReviewFocus []string `json:"review_focus,omitempty"`
|
||||
Recommendations []string `json:"recommendations,omitempty"`
|
||||
AttentionItems []string `json:"attention_items,omitempty"`
|
||||
NextSteps []string `json:"next_steps,omitempty"`
|
||||
BoundaryDescription string `json:"boundary_description"`
|
||||
Role string `json:"role"`
|
||||
Repository string `json:"repository"`
|
||||
RepositoryURL string `json:"repository_url,omitempty"`
|
||||
DocURL string `json:"doc_url,omitempty"`
|
||||
HealthScore *int `json:"health_score,omitempty"`
|
||||
HealthRisk string `json:"health_risk,omitempty"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ReportScore int `json:"report_score"`
|
||||
IssueTotal int `json:"issue_total"`
|
||||
IssueHighRisk int `json:"issue_high_risk"`
|
||||
IssueMissingInfo int `json:"issue_missing_info"`
|
||||
PRTotal int `json:"pr_total"`
|
||||
PRHighRisk int `json:"pr_high_risk"`
|
||||
PRRiskSources map[string]int `json:"pr_risk_sources,omitempty"`
|
||||
PRLifecycle *workflow.RepoPRLifecycle `json:"pr_lifecycle,omitempty"`
|
||||
PRReviewAudit *workflow.RepoPRReviewAudit `json:"pr_review_audit,omitempty"`
|
||||
ReviewFocus []string `json:"review_focus,omitempty"`
|
||||
Recommendations []string `json:"recommendations,omitempty"`
|
||||
AttentionItems []string `json:"attention_items,omitempty"`
|
||||
NextSteps []string `json:"next_steps,omitempty"`
|
||||
BoundaryDescription string `json:"boundary_description"`
|
||||
}
|
||||
|
||||
func BuildOwnerDigest(report workflow.RepoReportResult, docURL string) RoleDigest {
|
||||
|
|
@ -70,6 +74,9 @@ func BuildOwnerDigest(report workflow.RepoReportResult, docURL string) RoleDiges
|
|||
IssueMissingInfo: report.IssueSummary.MissingInfo,
|
||||
PRTotal: report.PRSummary.Total,
|
||||
PRHighRisk: report.PRSummary.HighRisk,
|
||||
PRRiskSources: report.PRSummary.RiskSources,
|
||||
PRLifecycle: report.PRLifecycle,
|
||||
PRReviewAudit: report.PRReviewAudit,
|
||||
ReviewFocus: report.PRSummary.ReviewFocus,
|
||||
Recommendations: report.Recommendations,
|
||||
AttentionItems: uniqueDigestStrings(attention),
|
||||
|
|
@ -118,6 +125,9 @@ func BuildContributorDigest(report workflow.RepoReportResult, docURL string) Rol
|
|||
IssueMissingInfo: report.IssueSummary.MissingInfo,
|
||||
PRTotal: report.PRSummary.Total,
|
||||
PRHighRisk: report.PRSummary.HighRisk,
|
||||
PRRiskSources: report.PRSummary.RiskSources,
|
||||
PRLifecycle: report.PRLifecycle,
|
||||
PRReviewAudit: report.PRReviewAudit,
|
||||
ReviewFocus: report.PRSummary.ReviewFocus,
|
||||
Recommendations: report.Recommendations,
|
||||
AttentionItems: limitStrings(uniqueDigestStrings(attention), 8),
|
||||
|
|
@ -140,8 +150,8 @@ func buildDigestCard(digest RoleDigest, title string, role string, lang string)
|
|||
fields([]fieldValue{
|
||||
{Label: feishuLabel(lang, "report_score"), Value: fmt.Sprintf("%d", digest.ReportScore)},
|
||||
{Label: feishuLabel(lang, "risk_level"), Value: digest.RiskLevel},
|
||||
{Label: feishuLabel(lang, "issues"), Value: fmt.Sprintf("%d", digest.IssueTotal)},
|
||||
{Label: feishuLabel(lang, "pull_requests"), Value: fmt.Sprintf("%d", digest.PRTotal)},
|
||||
{Label: feishuLabel(lang, "issues_analyzed"), Value: fmt.Sprintf("%d", digest.IssueTotal)},
|
||||
{Label: feishuLabel(lang, "prs_analyzed"), Value: fmt.Sprintf("%d", digest.PRTotal)},
|
||||
}),
|
||||
fields([]fieldValue{
|
||||
{Label: feishuLabel(lang, "high_risk_issues"), Value: fmt.Sprintf("%d", digest.IssueHighRisk)},
|
||||
|
|
@ -156,9 +166,31 @@ func buildDigestCard(digest RoleDigest, title string, role string, lang string)
|
|||
{Label: feishuLabel(lang, "health_risk"), Value: digest.HealthRisk},
|
||||
}))
|
||||
}
|
||||
if digest.PRLifecycle != nil {
|
||||
elements = append(elements, fields([]fieldValue{
|
||||
{Label: feishuLabel(lang, "open_prs"), Value: fmt.Sprintf("%d", digest.PRLifecycle.Open)},
|
||||
{Label: feishuLabel(lang, "merged_prs"), Value: fmt.Sprintf("%d", digest.PRLifecycle.Merged)},
|
||||
{Label: feishuLabel(lang, "closed_prs"), Value: fmt.Sprintf("%d", digest.PRLifecycle.ClosedOrRejected)},
|
||||
}))
|
||||
}
|
||||
if digest.PRReviewAudit != nil {
|
||||
elements = append(elements, fields([]fieldValue{
|
||||
{Label: feishuLabel(lang, "review_audited"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.Audited)},
|
||||
{Label: feishuLabel(lang, "reviewed_prs"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.Reviewed)},
|
||||
{Label: feishuLabel(lang, "unreviewed_prs"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.Unreviewed)},
|
||||
{Label: feishuLabel(lang, "needs_re_review"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.NeedsReReview)},
|
||||
{Label: feishuLabel(lang, "formal_reviews"), Value: fmt.Sprintf("%d", digest.PRReviewAudit.FormalReviews)},
|
||||
}))
|
||||
elements = append(elements, div(fmt.Sprintf("**%s**\n%s",
|
||||
feishuLabel(lang, "review_actor_attribution"),
|
||||
bulletList(reviewAuditActorLines(digest.PRReviewAudit, lang), 6))))
|
||||
}
|
||||
if len(digest.AttentionItems) > 0 {
|
||||
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "attention"), bulletList(localizeFeishuLines(digest.AttentionItems, lang), 5))))
|
||||
}
|
||||
if lines := riskSourceLines(digest.PRRiskSources); len(lines) > 0 {
|
||||
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "risk_sources"), bulletList(lines, 8))))
|
||||
}
|
||||
if len(digest.NextSteps) > 0 {
|
||||
elements = append(elements, div(fmt.Sprintf("**%s**\n%s", feishuLabel(lang, "suggested_next_steps"), bulletList(localizeFeishuLines(digest.NextSteps, lang), 5))))
|
||||
}
|
||||
|
|
@ -168,6 +200,7 @@ func buildDigestCard(digest RoleDigest, title string, role string, lang string)
|
|||
if digest.DocURL != "" {
|
||||
elements = append(elements, actionButton(feishuLabel(lang, "open_feishu_report"), digest.DocURL))
|
||||
}
|
||||
elements = append(elements, note(feishuLabel(lang, "analysis_scope")))
|
||||
elements = append(elements, note(localizedBoundary(digest, lang)))
|
||||
template := templateForRisk(digest.RiskLevel)
|
||||
if role == "contributor" && digest.PRSummaryNeedsAttention() {
|
||||
|
|
@ -228,6 +261,40 @@ func writeDigestMarkdown(w io.Writer, digest RoleDigest, lang string) error {
|
|||
lines = append(lines, fmt.Sprintf("- Health score: `%d`; health risk: `%s`", *digest.HealthScore, firstNonEmpty(digest.HealthRisk, "unknown")))
|
||||
}
|
||||
}
|
||||
if digest.PRLifecycle != nil {
|
||||
if isChineseLang(lang) {
|
||||
lines = append(lines, fmt.Sprintf("- PR 生命周期:开放 `%d`,已合并 `%d`,已关闭/拒绝 `%d`",
|
||||
digest.PRLifecycle.Open,
|
||||
digest.PRLifecycle.Merged,
|
||||
digest.PRLifecycle.ClosedOrRejected,
|
||||
))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- PR lifecycle: open `%d`, merged `%d`, closed/rejected `%d`",
|
||||
digest.PRLifecycle.Open,
|
||||
digest.PRLifecycle.Merged,
|
||||
digest.PRLifecycle.ClosedOrRejected,
|
||||
))
|
||||
}
|
||||
}
|
||||
if digest.PRReviewAudit != nil {
|
||||
if isChineseLang(lang) {
|
||||
lines = append(lines, fmt.Sprintf("- Review 判别:已归因 `%d`,已被 review `%d`,未被 review `%d`,待重新 review `%d`,正式 Review `%d`",
|
||||
digest.PRReviewAudit.Audited,
|
||||
digest.PRReviewAudit.Reviewed,
|
||||
digest.PRReviewAudit.Unreviewed,
|
||||
digest.PRReviewAudit.NeedsReReview,
|
||||
digest.PRReviewAudit.FormalReviews,
|
||||
))
|
||||
} else {
|
||||
lines = append(lines, fmt.Sprintf("- Review audit: audited `%d`, reviewed `%d`, unreviewed `%d`, needs re-review `%d`, formal reviews `%d`",
|
||||
digest.PRReviewAudit.Audited,
|
||||
digest.PRReviewAudit.Reviewed,
|
||||
digest.PRReviewAudit.Unreviewed,
|
||||
digest.PRReviewAudit.NeedsReReview,
|
||||
digest.PRReviewAudit.FormalReviews,
|
||||
))
|
||||
}
|
||||
}
|
||||
if digest.RepositoryURL != "" {
|
||||
if isChineseLang(lang) {
|
||||
lines = append(lines, "- GitLink 仓库:"+digest.RepositoryURL)
|
||||
|
|
@ -254,6 +321,12 @@ func writeDigestMarkdown(w io.Writer, digest RoleDigest, lang string) error {
|
|||
return err
|
||||
}
|
||||
}
|
||||
if lines := riskSourceLines(digest.PRRiskSources); len(lines) > 0 {
|
||||
heading := feishuLabel(lang, "risk_sources")
|
||||
if _, err := fmt.Fprintf(w, "\n## %s\n\n%s\n", heading, bulletList(lines, 8)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(digest.NextSteps) > 0 {
|
||||
heading := "Suggested next steps"
|
||||
if isChineseLang(lang) {
|
||||
|
|
@ -272,28 +345,42 @@ func digestMarkdownLines(digest RoleDigest, lang string) []string {
|
|||
return []string{
|
||||
fmt.Sprintf("- 报告分数:`%d`", digest.ReportScore),
|
||||
fmt.Sprintf("- 风险等级:`%s`", firstNonEmpty(digest.RiskLevel, "unknown")),
|
||||
fmt.Sprintf("- Issue:总数 `%d`,高风险 `%d`,信息缺失 `%d`", digest.IssueTotal, digest.IssueHighRisk, digest.IssueMissingInfo),
|
||||
fmt.Sprintf("- PR:总数 `%d`,高风险 `%d`", digest.PRTotal, digest.PRHighRisk),
|
||||
fmt.Sprintf("- 已分析 Issue:`%d`,其中高风险 `%d`,信息缺失 `%d`", digest.IssueTotal, digest.IssueHighRisk, digest.IssueMissingInfo),
|
||||
fmt.Sprintf("- 已分析 PR:`%d`,其中高风险 `%d`", digest.PRTotal, digest.PRHighRisk),
|
||||
"- " + feishuLabel(lang, "analysis_scope"),
|
||||
}
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("- Report score: `%d`", digest.ReportScore),
|
||||
fmt.Sprintf("- Risk level: `%s`", firstNonEmpty(digest.RiskLevel, "unknown")),
|
||||
fmt.Sprintf("- Issues: `%d` total, `%d` high risk, `%d` missing info", digest.IssueTotal, digest.IssueHighRisk, digest.IssueMissingInfo),
|
||||
fmt.Sprintf("- Pull requests: `%d` total, `%d` high risk", digest.PRTotal, digest.PRHighRisk),
|
||||
fmt.Sprintf("- Issues analyzed: `%d`, including `%d` high risk and `%d` missing info", digest.IssueTotal, digest.IssueHighRisk, digest.IssueMissingInfo),
|
||||
fmt.Sprintf("- Pull requests analyzed: `%d`, including `%d` high risk", digest.PRTotal, digest.PRHighRisk),
|
||||
"- " + feishuLabel(lang, "analysis_scope"),
|
||||
}
|
||||
}
|
||||
|
||||
func writeDigestTable(w io.Writer, digest RoleDigest, lang string) error {
|
||||
tw := tabwriter.NewWriter(w, 0, 0, 2, ' ', 0)
|
||||
header := "ROLE\tREPOSITORY\tRISK\tSCORE\tISSUES\tHIGH_RISK_ISSUES\tPRS\tHIGH_RISK_PRS\tATTENTION"
|
||||
header := "ROLE\tREPOSITORY\tRISK\tSCORE\tISSUES_ANALYZED\tHIGH_RISK_ISSUES\tPRS_ANALYZED\tHIGH_RISK_PRS\tOPEN_PRS\tMERGED_PRS\tCLOSED_PRS\tREVIEWED_PRS\tUNREVIEWED_PRS\tNEEDS_RE_REVIEW\tATTENTION"
|
||||
if isChineseLang(lang) {
|
||||
header = "角色\t仓库\t风险\t分数\tIssue\t高风险Issue\tPR\t高风险PR\t关注项"
|
||||
header = "角色\t仓库\t风险\t分数\t已分析Issue\t高风险Issue\t已分析PR\t高风险PR\t开放PR\t已合并PR\t已关闭PR\t已Review PR\t未Review PR\t待重新Review\t关注项"
|
||||
}
|
||||
if _, err := fmt.Fprintln(tw, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\n",
|
||||
openPRs, mergedPRs, closedPRs := 0, 0, 0
|
||||
if digest.PRLifecycle != nil {
|
||||
openPRs = digest.PRLifecycle.Open
|
||||
mergedPRs = digest.PRLifecycle.Merged
|
||||
closedPRs = digest.PRLifecycle.ClosedOrRejected
|
||||
}
|
||||
reviewedPRs, unreviewedPRs, needsReReviewPRs := 0, 0, 0
|
||||
if digest.PRReviewAudit != nil {
|
||||
reviewedPRs = digest.PRReviewAudit.Reviewed
|
||||
unreviewedPRs = digest.PRReviewAudit.Unreviewed
|
||||
needsReReviewPRs = digest.PRReviewAudit.NeedsReReview
|
||||
}
|
||||
if _, err := fmt.Fprintf(tw, "%s\t%s\t%s\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\t%d\n",
|
||||
digest.Role,
|
||||
digest.Repository,
|
||||
digest.RiskLevel,
|
||||
|
|
@ -302,6 +389,12 @@ func writeDigestTable(w io.Writer, digest RoleDigest, lang string) error {
|
|||
digest.IssueHighRisk,
|
||||
digest.PRTotal,
|
||||
digest.PRHighRisk,
|
||||
openPRs,
|
||||
mergedPRs,
|
||||
closedPRs,
|
||||
reviewedPRs,
|
||||
unreviewedPRs,
|
||||
needsReReviewPRs,
|
||||
len(digest.AttentionItems),
|
||||
); err != nil {
|
||||
return err
|
||||
|
|
@ -309,6 +402,34 @@ func writeDigestTable(w io.Writer, digest RoleDigest, lang string) error {
|
|||
return tw.Flush()
|
||||
}
|
||||
|
||||
func riskSourceLines(sources map[string]int) []string {
|
||||
if len(sources) == 0 {
|
||||
return nil
|
||||
}
|
||||
keys := make([]string, 0, len(sources))
|
||||
for key := range sources {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
lines := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
lines = append(lines, fmt.Sprintf("%s: %d", key, sources[key]))
|
||||
}
|
||||
return lines
|
||||
}
|
||||
|
||||
func reviewAuditActorLines(audit *workflow.RepoPRReviewAudit, lang string) []string {
|
||||
if audit == nil {
|
||||
return nil
|
||||
}
|
||||
return []string{
|
||||
fmt.Sprintf("%s: %d", feishuLabel(lang, "reviewer_comments"), audit.ReviewerComments),
|
||||
fmt.Sprintf("%s: %d", feishuLabel(lang, "submitter_comments"), audit.SubmitterComments),
|
||||
fmt.Sprintf("%s: %d", feishuLabel(lang, "participant_comments"), audit.ParticipantComments),
|
||||
fmt.Sprintf("%s: %d", feishuLabel(lang, "system_events"), audit.SystemEvents),
|
||||
}
|
||||
}
|
||||
|
||||
func digestHealth(report workflow.RepoReportResult) (*int, string) {
|
||||
if report.Health == nil {
|
||||
return nil, ""
|
||||
|
|
|
|||
|
|
@ -189,6 +189,26 @@ func BuildDocBlocks(report workflow.RepoReportResult, lang string) []DocBlock {
|
|||
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_health"), healthScore, healthRisk)),
|
||||
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_issues"), report.IssueSummary.Total, report.IssueSummary.HighRisk, report.IssueSummary.MissingInfo)),
|
||||
textBlock(fmt.Sprintf(feishuLabel(lang, "doc_prs"), report.PRSummary.Total, report.PRSummary.HighRisk)),
|
||||
textBlock(feishuLabel(lang, "analysis_scope")),
|
||||
}
|
||||
if report.PRLifecycle != nil {
|
||||
blocks = append(blocks, textBlock(fmt.Sprintf(
|
||||
"%s=%d; %s=%d; %s=%d",
|
||||
feishuLabel(lang, "open_prs"), report.PRLifecycle.Open,
|
||||
feishuLabel(lang, "merged_prs"), report.PRLifecycle.Merged,
|
||||
feishuLabel(lang, "closed_prs"), report.PRLifecycle.ClosedOrRejected,
|
||||
)))
|
||||
}
|
||||
if report.PRReviewAudit != nil {
|
||||
blocks = append(blocks, textBlock(fmt.Sprintf(
|
||||
"%s=%d; %s=%d; %s=%d; %s=%d; %s=%d",
|
||||
feishuLabel(lang, "review_audited"), report.PRReviewAudit.Audited,
|
||||
feishuLabel(lang, "reviewed_prs"), report.PRReviewAudit.Reviewed,
|
||||
feishuLabel(lang, "unreviewed_prs"), report.PRReviewAudit.Unreviewed,
|
||||
feishuLabel(lang, "needs_re_review"), report.PRReviewAudit.NeedsReReview,
|
||||
feishuLabel(lang, "formal_reviews"), report.PRReviewAudit.FormalReviews,
|
||||
)))
|
||||
blocks = append(blocks, textBlock(feishuLabel(lang, "review_actor_attribution")+":\n"+joinLines(reviewAuditActorLines(report.PRReviewAudit, lang), 6)))
|
||||
}
|
||||
if len(report.PRSummary.ReviewFocus) > 0 {
|
||||
blocks = append(blocks, textBlock(feishuLabel(lang, "doc_review_focus")+":\n"+joinLines(localizeFeishuLines(report.PRSummary.ReviewFocus, lang), 6)))
|
||||
|
|
|
|||
|
|
@ -77,6 +77,18 @@ func TestBuildWorkflowCardIncludesDocButton(t *testing.T) {
|
|||
if err != nil {
|
||||
t.Fatalf("readWorkflowReport returned error: %v", err)
|
||||
}
|
||||
report.PRSummary.RiskSources = map[string]int{"security-sensitive keyword": 2}
|
||||
report.PRReviewAudit = &workflow.RepoPRReviewAudit{
|
||||
Audited: 3,
|
||||
Reviewed: 2,
|
||||
Unreviewed: 1,
|
||||
NeedsReReview: 1,
|
||||
FormalReviews: 2,
|
||||
ReviewerComments: 4,
|
||||
SubmitterComments: 3,
|
||||
ParticipantComments: 1,
|
||||
SystemEvents: 2,
|
||||
}
|
||||
card := BuildWorkflowCard(report, parseList(defaultInclude), "", "en", "https://example.feishu.cn/wiki/node")
|
||||
encoded, err := json.Marshal(card)
|
||||
if err != nil {
|
||||
|
|
@ -85,6 +97,15 @@ func TestBuildWorkflowCardIncludesDocButton(t *testing.T) {
|
|||
if !strings.Contains(string(encoded), "Open Feishu report") {
|
||||
t.Fatalf("card missing doc button: %s", string(encoded))
|
||||
}
|
||||
if !strings.Contains(string(encoded), "Issues analyzed") || !strings.Contains(string(encoded), "not repository totals") {
|
||||
t.Fatalf("card missing analyzed-count boundary: %s", string(encoded))
|
||||
}
|
||||
if !strings.Contains(string(encoded), "PR risk rule sources") || !strings.Contains(string(encoded), "security-sensitive keyword: 2") {
|
||||
t.Fatalf("card missing PR risk sources: %s", string(encoded))
|
||||
}
|
||||
if !strings.Contains(string(encoded), "Reviewed PRs") || !strings.Contains(string(encoded), "Needs re-review") || !strings.Contains(string(encoded), "Reviewer comments: 4") {
|
||||
t.Fatalf("card missing PR review audit: %s", string(encoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestWebhookClientSendsPayload(t *testing.T) {
|
||||
|
|
@ -186,6 +207,11 @@ func TestOwnerAndContributorDigestMapping(t *testing.T) {
|
|||
if owner.IssueTotal != report.IssueSummary.Total || owner.PRTotal != report.PRSummary.Total {
|
||||
t.Fatalf("owner digest counts = %+v", owner)
|
||||
}
|
||||
report.PRReviewAudit = &workflow.RepoPRReviewAudit{Audited: 2, Reviewed: 1, Unreviewed: 1, NeedsReReview: 1, FormalReviews: 1}
|
||||
owner = BuildOwnerDigest(report, "https://tenant.feishu.cn/wiki/node")
|
||||
if owner.PRReviewAudit == nil || owner.PRReviewAudit.Reviewed != 1 {
|
||||
t.Fatalf("owner digest missing review audit: %+v", owner)
|
||||
}
|
||||
contributor := BuildContributorDigest(report, "")
|
||||
if contributor.Role != "contributor" {
|
||||
t.Fatalf("contributor digest role = %q", contributor.Role)
|
||||
|
|
@ -201,6 +227,9 @@ func TestOwnerAndContributorDigestMapping(t *testing.T) {
|
|||
if !strings.Contains(string(encoded), "Open GitLink repository") {
|
||||
t.Fatalf("owner card missing repository button: %s", string(encoded))
|
||||
}
|
||||
if !strings.Contains(string(encoded), "Issues analyzed") || !strings.Contains(string(encoded), "not repository totals") {
|
||||
t.Fatalf("owner card missing analyzed-count boundary: %s", string(encoded))
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskCandidatesAreStable(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -49,6 +49,7 @@ func localizeFeishuLines(values []string, lang string) []string {
|
|||
|
||||
var feishuLabelsEN = map[string]string{
|
||||
"attention": "Attention",
|
||||
"analysis_scope": "Counts are items analyzed from workflow JSON and may be limited by --issue-limit/--pr-limit; they are not repository totals.",
|
||||
"boundary_contributor": "Contributor digest is role-oriented, not personalized. It does not use Feishu open_id or union_id routing.",
|
||||
"boundary_owner": "Owner digest is a read-only summary. It does not modify GitLink or Feishu resources.",
|
||||
"bot_generated": "Generated by gitlink-cli feishu +bot-test.",
|
||||
|
|
@ -56,8 +57,8 @@ var feishuLabelsEN = map[string]string{
|
|||
"bot_status": "Status",
|
||||
"bot_title": "GitLink Feishu integration test",
|
||||
"doc_health": "Health score: %s; health risk: %s",
|
||||
"doc_issues": "Issues: total=%d, high_risk=%d, missing_info=%d",
|
||||
"doc_prs": "Pull Requests: total=%d, high_risk=%d",
|
||||
"doc_issues": "Issues analyzed: %d; high_risk=%d, missing_info=%d",
|
||||
"doc_prs": "Pull Requests analyzed: %d; high_risk=%d",
|
||||
"doc_reasoning": "Reasoning",
|
||||
"doc_recommendations": "Recommendations",
|
||||
"doc_report_score": "Report score: %d",
|
||||
|
|
@ -70,20 +71,36 @@ var feishuLabelsEN = map[string]string{
|
|||
"high_risk_issues": "High-risk issues",
|
||||
"high_risk_prs": "High-risk PRs",
|
||||
"issues": "Issues",
|
||||
"issues_analyzed": "Issues analyzed",
|
||||
"missing_info": "Missing info",
|
||||
"missing_info_issues": "Missing-info issues",
|
||||
"merged_prs": "Merged PRs",
|
||||
"closed_prs": "Closed/rejected PRs",
|
||||
"open_prs": "Open PRs",
|
||||
"open_feishu_report": "Open Feishu report",
|
||||
"open_gitlink_repository": "Open GitLink repository",
|
||||
"owner_digest_title": "GitLink owner digest: %s",
|
||||
"contributor_digest_title": "GitLink contributor digest: %s",
|
||||
"preview_note": "Preview is read-only. Bitable records are generated locally by +bitable-records.",
|
||||
"pull_requests": "Pull requests",
|
||||
"prs_analyzed": "PRs analyzed",
|
||||
"ready": "Ready",
|
||||
"recommendations": "Recommendations",
|
||||
"report_score": "Report score",
|
||||
"repository": "Repository",
|
||||
"review_focus": "Review focus",
|
||||
"review_audited": "PRs review-audited",
|
||||
"reviewed_prs": "Reviewed PRs",
|
||||
"unreviewed_prs": "Unreviewed PRs",
|
||||
"needs_re_review": "Needs re-review",
|
||||
"formal_reviews": "Formal reviews",
|
||||
"review_actor_attribution": "Review actor attribution",
|
||||
"reviewer_comments": "Reviewer comments",
|
||||
"submitter_comments": "Submitter comments",
|
||||
"participant_comments": "Participant comments",
|
||||
"system_events": "System events",
|
||||
"risk_level": "Risk level",
|
||||
"risk_sources": "PR risk rule sources",
|
||||
"source": "Source",
|
||||
"suggested_next_steps": "Suggested next steps",
|
||||
"task_description_default": "Workflow recommendation from gitlink-cli repo report.",
|
||||
|
|
@ -96,6 +113,7 @@ var feishuLabelsEN = map[string]string{
|
|||
|
||||
var feishuLabelsZH = map[string]string{
|
||||
"attention": "需要关注",
|
||||
"analysis_scope": "数量表示 workflow JSON 中实际分析的条目,可能受 --issue-limit/--pr-limit 限制,不代表仓库总量。",
|
||||
"boundary_contributor": "贡献者摘要是按角色生成的汇总,不是基于飞书 open_id 或 union_id 的个人定向推送。",
|
||||
"boundary_owner": "Owner 摘要是只读汇总,不会修改 GitLink 或飞书资源。",
|
||||
"bot_generated": "由 gitlink-cli feishu +bot-test 生成。",
|
||||
|
|
@ -103,8 +121,8 @@ var feishuLabelsZH = map[string]string{
|
|||
"bot_status": "状态",
|
||||
"bot_title": "GitLink 飞书集成测试",
|
||||
"doc_health": "健康分:%s;健康风险:%s",
|
||||
"doc_issues": "Issue:总数=%d,高风险=%d,信息缺失=%d",
|
||||
"doc_prs": "PR:总数=%d,高风险=%d",
|
||||
"doc_issues": "已分析 Issue:%d;高风险=%d,信息缺失=%d",
|
||||
"doc_prs": "已分析 PR:%d;高风险=%d",
|
||||
"doc_reasoning": "判断依据",
|
||||
"doc_recommendations": "建议操作",
|
||||
"doc_report_score": "报告分数:%d",
|
||||
|
|
@ -117,20 +135,36 @@ var feishuLabelsZH = map[string]string{
|
|||
"high_risk_issues": "高风险 Issue",
|
||||
"high_risk_prs": "高风险 PR",
|
||||
"issues": "Issue",
|
||||
"issues_analyzed": "已分析 Issue",
|
||||
"missing_info": "信息缺失",
|
||||
"missing_info_issues": "信息缺失 Issue",
|
||||
"merged_prs": "已合并 PR",
|
||||
"closed_prs": "已关闭/拒绝 PR",
|
||||
"open_prs": "开放 PR",
|
||||
"open_feishu_report": "打开飞书报告",
|
||||
"open_gitlink_repository": "打开 GitLink 仓库",
|
||||
"owner_digest_title": "GitLink Owner 摘要:%s",
|
||||
"contributor_digest_title": "GitLink 贡献者摘要:%s",
|
||||
"preview_note": "当前为只读预览。多维表格记录由 +bitable-records 在本地生成。",
|
||||
"pull_requests": "PR",
|
||||
"prs_analyzed": "已分析 PR",
|
||||
"ready": "就绪",
|
||||
"recommendations": "建议操作",
|
||||
"report_score": "报告分数",
|
||||
"repository": "仓库",
|
||||
"review_focus": "审查重点",
|
||||
"review_audited": "已审查归因 PR",
|
||||
"reviewed_prs": "已被 review 的 PR",
|
||||
"unreviewed_prs": "未被 review 的 PR",
|
||||
"needs_re_review": "待重新 review",
|
||||
"formal_reviews": "正式 Review",
|
||||
"review_actor_attribution": "Review 评论来源归因",
|
||||
"reviewer_comments": "Reviewer 评论",
|
||||
"submitter_comments": "提交者评论",
|
||||
"participant_comments": "参与者评论",
|
||||
"system_events": "系统事件",
|
||||
"risk_level": "风险等级",
|
||||
"risk_sources": "PR 风险规则来源",
|
||||
"source": "来源",
|
||||
"suggested_next_steps": "建议下一步",
|
||||
"task_description_default": "来自 gitlink-cli 仓库报告的工作流建议。",
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ func apiList(data interface{}) []interface{} {
|
|||
case []interface{}:
|
||||
return v
|
||||
case map[string]interface{}:
|
||||
for _, key := range []string{"issues", "pulls", "pull_requests", "files", "commits", "releases", "builds", "items", "records", "data"} {
|
||||
for _, key := range []string{"issues", "pulls", "pull_requests", "reviews", "journals", "comments", "notes", "files", "commits", "releases", "builds", "items", "records", "data"} {
|
||||
if raw, ok := v[key]; ok {
|
||||
if items := apiList(raw); len(items) > 0 {
|
||||
return items
|
||||
|
|
|
|||
|
|
@ -495,8 +495,27 @@ func TestQueryWithPageLimit(t *testing.T) {
|
|||
|
||||
func TestIssueListQuery(t *testing.T) {
|
||||
q := issueListQuery("open")
|
||||
if q.Get("state") != "open" {
|
||||
t.Fatalf("issueListQuery state = %q", q.Get("state"))
|
||||
if q.Get("category") != "opened" || q.Get("state") != "" {
|
||||
t.Fatalf("issueListQuery = %v, want category=opened", q)
|
||||
}
|
||||
q = issueListQuery("closed")
|
||||
if q.Get("category") != "closed" {
|
||||
t.Fatalf("issueListQuery closed = %v", q)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPullListQuery(t *testing.T) {
|
||||
q := pullListQuery("open")
|
||||
if q.Get("status") != "0" || q.Get("state") != "" {
|
||||
t.Fatalf("pullListQuery = %v, want status=0", q)
|
||||
}
|
||||
q = pullListQuery("merged")
|
||||
if q.Get("status") != "1" {
|
||||
t.Fatalf("pullListQuery merged = %v", q)
|
||||
}
|
||||
q = pullListQuery("closed")
|
||||
if q.Get("status") != "2" {
|
||||
t.Fatalf("pullListQuery closed = %v", q)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -39,7 +39,7 @@ func FetchHealthInput(ctx *common.RuntimeContext, opts HealthFetchOptions) (Heal
|
|||
input.RecentActivityKnown, input.RecentActivityDays, input = updateRecentActivity(input, latestTimeFromItems(issues))
|
||||
}
|
||||
|
||||
if prs, err := fetchAllListItems(ctx, workflowRepoPath(owner, repo)+"/pulls", issueListQuery("open"), 100); err != nil {
|
||||
if prs, err := fetchAllListItems(ctx, workflowRepoPath(owner, repo)+"/pulls", pullListQuery("open"), 100); err != nil {
|
||||
notes = append(notes, ScoringNote{Metric: "open_prs", Note: fmt.Sprintf("pull request probe failed: %v", err)})
|
||||
} else {
|
||||
input.OpenPRs = len(prs)
|
||||
|
|
@ -222,16 +222,50 @@ func queryWithPageLimit(base url.Values, page, limit int) url.Values {
|
|||
|
||||
func issueListQuery(state string) url.Values {
|
||||
q := url.Values{}
|
||||
q.Set("state", state)
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "open", "opened", "":
|
||||
q.Set("category", "opened")
|
||||
case "closed":
|
||||
q.Set("category", "closed")
|
||||
case "all":
|
||||
q.Set("category", "all")
|
||||
default:
|
||||
q.Set("category", state)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func pullListQuery(state string) url.Values {
|
||||
q := url.Values{}
|
||||
switch strings.ToLower(strings.TrimSpace(state)) {
|
||||
case "open", "opened":
|
||||
q.Set("status", "0")
|
||||
case "merged":
|
||||
q.Set("status", "1")
|
||||
case "closed":
|
||||
q.Set("status", "2")
|
||||
case "all", "":
|
||||
default:
|
||||
q.Set("status", state)
|
||||
}
|
||||
return q
|
||||
}
|
||||
|
||||
func fetchAllListItems(ctx *common.RuntimeContext, path string, baseQuery url.Values, pageSize int) ([]map[string]interface{}, error) {
|
||||
return fetchListItems(ctx, path, baseQuery, pageSize, 0)
|
||||
}
|
||||
|
||||
func fetchListItems(ctx *common.RuntimeContext, path string, baseQuery url.Values, pageSize, maxItems int) ([]map[string]interface{}, error) {
|
||||
if pageSize <= 0 {
|
||||
pageSize = 100
|
||||
pageSize = 50
|
||||
}
|
||||
if maxItems > 0 && pageSize > maxItems {
|
||||
pageSize = maxItems
|
||||
}
|
||||
all := []map[string]interface{}{}
|
||||
for page := 1; ; page++ {
|
||||
seen := map[string]struct{}{}
|
||||
totalCount := 0
|
||||
for page := 1; page <= 1000; page++ {
|
||||
query := cloneValues(baseQuery)
|
||||
query.Set("page", fmt.Sprintf("%d", page))
|
||||
query.Set("limit", fmt.Sprintf("%d", pageSize))
|
||||
|
|
@ -240,6 +274,12 @@ func fetchAllListItems(ctx *common.RuntimeContext, path string, baseQuery url.Va
|
|||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if env.Meta != nil && env.Meta.TotalCount > totalCount {
|
||||
totalCount = env.Meta.TotalCount
|
||||
}
|
||||
if dataTotal := apiListTotal(env.Data); dataTotal > totalCount {
|
||||
totalCount = dataTotal
|
||||
}
|
||||
items := apiList(env.Data)
|
||||
pageItems := make([]map[string]interface{}, 0, len(items))
|
||||
for _, raw := range items {
|
||||
|
|
@ -250,14 +290,52 @@ func fetchAllListItems(ctx *common.RuntimeContext, path string, baseQuery url.Va
|
|||
if len(pageItems) == 0 {
|
||||
break
|
||||
}
|
||||
all = append(all, pageItems...)
|
||||
if len(pageItems) < pageSize {
|
||||
before := len(all)
|
||||
for _, item := range pageItems {
|
||||
key := listItemIdentity(item)
|
||||
if key != "" {
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
all = append(all, item)
|
||||
if maxItems > 0 && len(all) >= maxItems {
|
||||
return all[:maxItems], nil
|
||||
}
|
||||
}
|
||||
if totalCount > 0 && len(all) >= totalCount {
|
||||
break
|
||||
}
|
||||
if len(all) == before {
|
||||
break
|
||||
}
|
||||
if totalCount == 0 && len(pageItems) < pageSize {
|
||||
break
|
||||
}
|
||||
}
|
||||
return all, nil
|
||||
}
|
||||
|
||||
func apiListTotal(data interface{}) int {
|
||||
object := apiObject(data)
|
||||
for _, key := range []string{"total_count", "opened_count", "total"} {
|
||||
if total := apiInt(object[key]); total > 0 {
|
||||
return total
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func listItemIdentity(item map[string]interface{}) string {
|
||||
for _, key := range []string{"id", "database_id", "index", "number", "iid", "project_issues_index"} {
|
||||
if value := apiString(item[key]); value != "" {
|
||||
return key + ":" + value
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func cloneValues(values url.Values) url.Values {
|
||||
if values == nil {
|
||||
return url.Values{}
|
||||
|
|
|
|||
|
|
@ -156,7 +156,7 @@ func prAPIObject(data interface{}) map[string]interface{} {
|
|||
}
|
||||
|
||||
func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool) {
|
||||
number := firstPRInt(item, "number", "iid", "pull_request_number")
|
||||
number := firstPRInt(item, "number", "index", "iid", "pull_request_number")
|
||||
title := firstPRString(item, "title", "subject")
|
||||
if number == 0 && strings.TrimSpace(title) == "" {
|
||||
return PRSummaryInput{}, false
|
||||
|
|
@ -164,18 +164,28 @@ func normalizePRSummaryItem(item map[string]interface{}) (PRSummaryInput, bool)
|
|||
body := firstPRString(item, "body", "description", "content")
|
||||
state := firstPRString(item, "state", "status")
|
||||
author := firstPRAuthor(item)
|
||||
issueID := firstPRIssueID(item)
|
||||
base := firstPRBranch(item, "base_branch", "target_branch", "base")
|
||||
head := firstPRBranch(item, "head_branch", "source_branch", "head")
|
||||
createdAt := firstPRTime(item, "created_at", "createdAt")
|
||||
updatedAt := apiLatestTime(
|
||||
firstPRTime(item, "updated_at", "updatedAt"),
|
||||
firstPRTime(item, "last_updated_at", "lastUpdatedAt"),
|
||||
firstPRTime(item, "last_activity_at", "lastActivityAt"),
|
||||
)
|
||||
additions := firstPRInt(item, "additions", "additions_count")
|
||||
deletions := firstPRInt(item, "deletions", "deletions_count")
|
||||
|
||||
return PRSummaryInput{
|
||||
Number: number,
|
||||
IssueID: issueID,
|
||||
Title: title,
|
||||
Author: author,
|
||||
State: state,
|
||||
BaseBranch: base,
|
||||
HeadBranch: head,
|
||||
CreatedAt: createdAt,
|
||||
UpdatedAt: updatedAt,
|
||||
Body: body,
|
||||
Additions: additions,
|
||||
Deletions: deletions,
|
||||
|
|
@ -298,6 +308,26 @@ func firstPRCommitAuthor(item map[string]interface{}) string {
|
|||
return ""
|
||||
}
|
||||
|
||||
func firstPRIssueID(item map[string]interface{}) int {
|
||||
for _, key := range []string{"issue_id", "issueId"} {
|
||||
if value, ok := item[key]; ok {
|
||||
if id := apiInt(value); id != 0 {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"issue", "issue_info"} {
|
||||
if raw, ok := item[key].(map[string]interface{}); ok {
|
||||
for _, field := range []string{"id", "issue_id"} {
|
||||
if id := apiInt(raw[field]); id != 0 {
|
||||
return id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func firstPRBranch(item map[string]interface{}, keys ...string) string {
|
||||
for _, key := range keys {
|
||||
if value, ok := item[key]; ok {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,354 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
const (
|
||||
reviewStandardFormal = "formal_review"
|
||||
reviewStandardReviewerJournal = "reviewer_journal_feedback"
|
||||
reviewStandardUnreviewed = "unreviewed"
|
||||
|
||||
actorSubmitter = "submitter"
|
||||
actorReviewer = "reviewer"
|
||||
actorParticipant = "participant"
|
||||
actorBot = "bot"
|
||||
actorSystem = "system"
|
||||
actorUnknown = "unknown"
|
||||
)
|
||||
|
||||
type normalizedPRReview struct {
|
||||
Actor string
|
||||
Status string
|
||||
At time.Time
|
||||
}
|
||||
|
||||
func fetchPRReviewAudit(ctx *common.RuntimeContext, owner, repo string, prs []PRSummaryInput, maxItems int) (*RepoPRReviewAudit, []ScoringNote) {
|
||||
if maxItems > 0 && len(prs) > maxItems {
|
||||
prs = prs[:maxItems]
|
||||
}
|
||||
audit := &RepoPRReviewAudit{
|
||||
Source: "remote-read-only-fetch:reviews-and-journals",
|
||||
PullRequests: make([]PRReviewAudit, 0, len(prs)),
|
||||
}
|
||||
notes := []ScoringNote{}
|
||||
for _, pr := range prs {
|
||||
item, itemNotes := fetchOnePRReviewAudit(ctx, owner, repo, pr)
|
||||
notes = append(notes, itemNotes...)
|
||||
audit.PullRequests = append(audit.PullRequests, item)
|
||||
audit.Audited++
|
||||
if item.Reviewed {
|
||||
audit.Reviewed++
|
||||
} else {
|
||||
audit.Unreviewed++
|
||||
}
|
||||
if item.NeedsReReview {
|
||||
audit.NeedsReReview++
|
||||
}
|
||||
audit.FormalReviews += item.FormalReviewCount
|
||||
audit.ReviewerComments += item.ReviewerComments
|
||||
audit.SubmitterComments += item.SubmitterComments
|
||||
audit.ParticipantComments += item.ParticipantComments
|
||||
audit.BotEvents += item.BotEvents
|
||||
audit.SystemEvents += item.SystemEvents
|
||||
audit.UnknownActorEvents += item.UnknownActorEvents
|
||||
if len(item.Notes) > 0 {
|
||||
audit.Errors += len(item.Notes)
|
||||
}
|
||||
}
|
||||
return audit, uniqueScoringNotes(notes)
|
||||
}
|
||||
|
||||
func fetchOnePRReviewAudit(ctx *common.RuntimeContext, owner, repo string, pr PRSummaryInput) (PRReviewAudit, []ScoringNote) {
|
||||
result := PRReviewAudit{
|
||||
Number: pr.Number,
|
||||
Author: pr.Author,
|
||||
IssueID: pr.IssueID,
|
||||
ReviewStandard: reviewStandardUnreviewed,
|
||||
FormalReviewStatus: "unreviewed",
|
||||
}
|
||||
notes := []ScoringNote{}
|
||||
if result.Number <= 0 {
|
||||
result.Notes = append(result.Notes, "missing PR number")
|
||||
return result, []ScoringNote{{Metric: "repo_report_pr_review_audit", Note: "skipped PR review audit: missing PR number"}}
|
||||
}
|
||||
|
||||
if result.IssueID == 0 || strings.TrimSpace(result.Author) == "" {
|
||||
base, err := fetchPRBase(ctx, owner, repo, result.Number)
|
||||
if err != nil {
|
||||
note := fmt.Sprintf("PR #%d base detail unavailable for review audit: %v", result.Number, err)
|
||||
result.Notes = append(result.Notes, note)
|
||||
notes = append(notes, ScoringNote{Metric: "repo_report_pr_review_audit", Note: note})
|
||||
} else {
|
||||
if result.IssueID == 0 {
|
||||
result.IssueID = base.IssueID
|
||||
}
|
||||
if strings.TrimSpace(result.Author) == "" {
|
||||
result.Author = base.Author
|
||||
}
|
||||
if pr.CreatedAt.IsZero() {
|
||||
pr.CreatedAt = base.CreatedAt
|
||||
}
|
||||
if pr.UpdatedAt.IsZero() {
|
||||
pr.UpdatedAt = base.UpdatedAt
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
reviews, err := fetchFormalPRReviews(ctx, owner, repo, result.Number)
|
||||
if err != nil {
|
||||
note := fmt.Sprintf("PR #%d formal reviews unavailable: %v", result.Number, err)
|
||||
result.Notes = append(result.Notes, note)
|
||||
notes = append(notes, ScoringNote{Metric: "repo_report_pr_review_audit", Note: note})
|
||||
} else {
|
||||
result.FormalReviewCount = len(reviews)
|
||||
result.FormalReviewStatus = summarizeFormalReviewStatus(reviews)
|
||||
result.Reviewers = uniqueReviewerNames(reviews)
|
||||
if latest := latestReviewTime(reviews); !latest.IsZero() {
|
||||
result.LatestReviewerAt = latest.Format(time.RFC3339)
|
||||
}
|
||||
if result.FormalReviewCount > 0 {
|
||||
result.Reviewed = true
|
||||
result.ReviewStandard = reviewStandardFormal
|
||||
}
|
||||
}
|
||||
|
||||
reviewerSet := reviewerSet(reviews)
|
||||
var latestReviewerAt time.Time
|
||||
if result.LatestReviewerAt != "" {
|
||||
latestReviewerAt = apiTime(result.LatestReviewerAt)
|
||||
}
|
||||
var latestSubmitterAt time.Time
|
||||
if result.IssueID == 0 {
|
||||
note := fmt.Sprintf("PR #%d conversation journal skipped: missing associated issue id", result.Number)
|
||||
result.Notes = append(result.Notes, note)
|
||||
notes = append(notes, ScoringNote{Metric: "repo_report_pr_review_audit", Note: note})
|
||||
} else {
|
||||
journals, err := fetchPRJournals(ctx, owner, repo, result.IssueID)
|
||||
if err != nil {
|
||||
note := fmt.Sprintf("PR #%d conversation journal unavailable: %v", result.Number, err)
|
||||
result.Notes = append(result.Notes, note)
|
||||
notes = append(notes, ScoringNote{Metric: "repo_report_pr_review_audit", Note: note})
|
||||
} else {
|
||||
for _, journal := range journals {
|
||||
switch classifyJournalActor(journal, result.Author, reviewerSet) {
|
||||
case actorReviewer:
|
||||
result.ReviewerComments++
|
||||
latestReviewerAt = apiLatestTime(latestReviewerAt, journalTime(journal))
|
||||
case actorSubmitter:
|
||||
result.SubmitterComments++
|
||||
latestSubmitterAt = apiLatestTime(latestSubmitterAt, journalTime(journal))
|
||||
case actorParticipant:
|
||||
result.ParticipantComments++
|
||||
case actorBot:
|
||||
result.BotEvents++
|
||||
case actorSystem:
|
||||
result.SystemEvents++
|
||||
default:
|
||||
result.UnknownActorEvents++
|
||||
}
|
||||
}
|
||||
if !result.Reviewed && result.ReviewerComments > 0 {
|
||||
result.Reviewed = true
|
||||
result.ReviewStandard = reviewStandardReviewerJournal
|
||||
}
|
||||
}
|
||||
}
|
||||
if !latestReviewerAt.IsZero() {
|
||||
result.LatestReviewerAt = latestReviewerAt.Format(time.RFC3339)
|
||||
}
|
||||
if !latestSubmitterAt.IsZero() {
|
||||
result.LatestSubmitterAt = latestSubmitterAt.Format(time.RFC3339)
|
||||
}
|
||||
latestCommitAt := latestCommitTime(pr.Commits)
|
||||
if !latestCommitAt.IsZero() {
|
||||
result.LatestCommitAt = latestCommitAt.Format(time.RFC3339)
|
||||
}
|
||||
if !pr.UpdatedAt.IsZero() {
|
||||
result.LatestPRUpdateAt = pr.UpdatedAt.Format(time.RFC3339)
|
||||
}
|
||||
result.NeedsReReview = needsReReview(latestReviewerAt, latestSubmitterAt, latestCommitAt, pr.UpdatedAt)
|
||||
|
||||
return result, notes
|
||||
}
|
||||
|
||||
func fetchFormalPRReviews(ctx *common.RuntimeContext, owner, repo string, number int) ([]normalizedPRReview, error) {
|
||||
items, err := fetchListItems(ctx, prPath(owner, repo, number)+"/reviews", url.Values{}, 50, 0)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reviews := make([]normalizedPRReview, 0, len(items))
|
||||
for _, item := range items {
|
||||
review := normalizedPRReview{
|
||||
Actor: firstReviewActor(item),
|
||||
Status: strings.ToLower(strings.TrimSpace(firstPRString(item, "status", "state", "review_status"))),
|
||||
At: journalTime(item),
|
||||
}
|
||||
if review.Actor == "" && review.Status == "" {
|
||||
continue
|
||||
}
|
||||
if review.Status == "" {
|
||||
review.Status = "common"
|
||||
}
|
||||
reviews = append(reviews, review)
|
||||
}
|
||||
return reviews, nil
|
||||
}
|
||||
|
||||
func latestReviewTime(reviews []normalizedPRReview) time.Time {
|
||||
var latest time.Time
|
||||
for _, review := range reviews {
|
||||
latest = apiLatestTime(latest, review.At)
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func journalTime(item map[string]interface{}) time.Time {
|
||||
return apiLatestTime(
|
||||
firstPRTime(item, "updated_at", "updatedAt"),
|
||||
firstPRTime(item, "created_at", "createdAt"),
|
||||
)
|
||||
}
|
||||
|
||||
func latestCommitTime(commits []PRCommit) time.Time {
|
||||
var latest time.Time
|
||||
for _, commit := range commits {
|
||||
latest = apiLatestTime(latest, commit.Date)
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
func needsReReview(latestReviewerAt, latestSubmitterAt, latestCommitAt, latestPRUpdateAt time.Time) bool {
|
||||
if latestReviewerAt.IsZero() {
|
||||
return false
|
||||
}
|
||||
return isAfter(latestSubmitterAt, latestReviewerAt) ||
|
||||
isAfter(latestCommitAt, latestReviewerAt) ||
|
||||
isAfter(latestPRUpdateAt, latestReviewerAt)
|
||||
}
|
||||
|
||||
func isAfter(value, baseline time.Time) bool {
|
||||
return !value.IsZero() && !baseline.IsZero() && value.After(baseline)
|
||||
}
|
||||
|
||||
func fetchPRJournals(ctx *common.RuntimeContext, owner, repo string, issueID int) ([]map[string]interface{}, error) {
|
||||
return fetchListItems(ctx, fmt.Sprintf("/v1/%s/%s/issues/%d/journals", owner, repo, issueID), url.Values{}, 50, 0)
|
||||
}
|
||||
|
||||
func firstReviewActor(item map[string]interface{}) string {
|
||||
for _, key := range []string{"reviewer", "user", "author", "creator"} {
|
||||
if value, ok := item[key]; ok {
|
||||
if actor := apiAuthor(value); actor != "" {
|
||||
return actor
|
||||
}
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func summarizeFormalReviewStatus(reviews []normalizedPRReview) string {
|
||||
if len(reviews) == 0 {
|
||||
return "unreviewed"
|
||||
}
|
||||
hasApproved := false
|
||||
hasCommon := false
|
||||
for _, review := range reviews {
|
||||
switch strings.ToLower(strings.TrimSpace(review.Status)) {
|
||||
case "rejected", "reject", "changes_requested", "request_changes":
|
||||
return "rejected"
|
||||
case "approved", "approve":
|
||||
hasApproved = true
|
||||
default:
|
||||
hasCommon = true
|
||||
}
|
||||
}
|
||||
if hasApproved {
|
||||
return "approved"
|
||||
}
|
||||
if hasCommon {
|
||||
return "common"
|
||||
}
|
||||
return "reviewed"
|
||||
}
|
||||
|
||||
func uniqueReviewerNames(reviews []normalizedPRReview) []string {
|
||||
set := map[string]string{}
|
||||
for _, review := range reviews {
|
||||
key := normalizeActorID(review.Actor)
|
||||
if key != "" {
|
||||
set[key] = review.Actor
|
||||
}
|
||||
}
|
||||
keys := make([]string, 0, len(set))
|
||||
for key := range set {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Strings(keys)
|
||||
out := make([]string, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
out = append(out, set[key])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func reviewerSet(reviews []normalizedPRReview) map[string]bool {
|
||||
set := map[string]bool{}
|
||||
for _, review := range reviews {
|
||||
if key := normalizeActorID(review.Actor); key != "" {
|
||||
set[key] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
func classifyJournalActor(item map[string]interface{}, author string, reviewers map[string]bool) string {
|
||||
category := strings.ToLower(strings.TrimSpace(firstPRString(item, "operate_category", "category", "type", "event")))
|
||||
content := strings.TrimSpace(firstPRString(item, "notes", "note", "body", "content", "operate_content"))
|
||||
actor := firstReviewActor(item)
|
||||
if isSystemJournalEvent(category, content, actor) {
|
||||
return actorSystem
|
||||
}
|
||||
if actor == "" {
|
||||
return actorUnknown
|
||||
}
|
||||
if isBotActor(actor) {
|
||||
return actorBot
|
||||
}
|
||||
actorID := normalizeActorID(actor)
|
||||
if actorID != "" && actorID == normalizeActorID(author) {
|
||||
return actorSubmitter
|
||||
}
|
||||
if actorID != "" && reviewers[actorID] {
|
||||
return actorReviewer
|
||||
}
|
||||
return actorParticipant
|
||||
}
|
||||
|
||||
func isSystemJournalEvent(category, content, actor string) bool {
|
||||
if strings.TrimSpace(content) == "" {
|
||||
return true
|
||||
}
|
||||
switch category {
|
||||
case "status", "state", "system", "relation", "assignee", "label", "milestone":
|
||||
return true
|
||||
}
|
||||
if strings.TrimSpace(actor) == "" && category != "" {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func isBotActor(actor string) bool {
|
||||
actor = strings.ToLower(strings.TrimSpace(actor))
|
||||
return strings.Contains(actor, "bot") || strings.Contains(actor, "机器人") || strings.Contains(actor, "automation")
|
||||
}
|
||||
|
||||
func normalizeActorID(actor string) string {
|
||||
return strings.ToLower(strings.TrimSpace(actor))
|
||||
}
|
||||
|
|
@ -32,11 +32,14 @@ const (
|
|||
type PRSummaryInput struct {
|
||||
Repository string `json:"repository"`
|
||||
Number int `json:"number"`
|
||||
IssueID int `json:"issue_id,omitempty"`
|
||||
Title string `json:"title"`
|
||||
Author string `json:"author"`
|
||||
State string `json:"state"`
|
||||
BaseBranch string `json:"base_branch"`
|
||||
HeadBranch string `json:"head_branch"`
|
||||
CreatedAt time.Time `json:"created_at,omitempty"`
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
Body string `json:"body,omitempty"`
|
||||
ChangedFiles []PRChangedFile `json:"changed_files"`
|
||||
Commits []PRCommit `json:"commits"`
|
||||
|
|
@ -75,6 +78,7 @@ type PRSummaryResult struct {
|
|||
CommitCount int `json:"commit_count"`
|
||||
ChangeType string `json:"change_type"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
RiskReasons []string `json:"risk_reasons,omitempty"`
|
||||
ReviewFocus []string `json:"review_focus"`
|
||||
TestSuggestions []string `json:"test_suggestions"`
|
||||
MergeChecklist []string `json:"merge_checklist"`
|
||||
|
|
@ -208,6 +212,7 @@ func AnalyzePRSummary(input PRSummaryInput, lang string) PRSummaryResult {
|
|||
CommitCount: len(input.Commits),
|
||||
ChangeType: changeType,
|
||||
RiskLevel: riskLevel,
|
||||
RiskReasons: riskReasons,
|
||||
ReviewFocus: reviewFocus,
|
||||
TestSuggestions: testSuggestions,
|
||||
MergeChecklist: mergeChecklist,
|
||||
|
|
|
|||
|
|
@ -82,6 +82,9 @@ func TestAnalyzePRSummaryAuthTokenCriticalRisk(t *testing.T) {
|
|||
if result.RiskLevel != PRRiskCritical {
|
||||
t.Fatalf("RiskLevel = %q, want %q", result.RiskLevel, PRRiskCritical)
|
||||
}
|
||||
if len(result.RiskReasons) != 1 || result.RiskReasons[0] != "security-sensitive keyword" {
|
||||
t.Fatalf("RiskReasons = %v, want security-sensitive keyword", result.RiskReasons)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzePRSummaryMixedFiles(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -164,10 +164,22 @@ func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) err
|
|||
if len(result.Recommendations) > 0 {
|
||||
topRecommendation = truncateTableText(result.Recommendations[0], 96)
|
||||
}
|
||||
if _, err := fmt.Fprintln(tw, "REPOSITORY\tREPORT_SCORE\tRISK\tHEALTH_SCORE\tISSUES\tHIGH_RISK_ISSUES\tPRS\tHIGH_RISK_PRS\tTOP_RECOMMENDATION"); err != nil {
|
||||
openPRs, mergedPRs, closedPRs := "N/A", "N/A", "N/A"
|
||||
if result.PRLifecycle != nil {
|
||||
openPRs = fmt.Sprintf("%d", result.PRLifecycle.Open)
|
||||
mergedPRs = fmt.Sprintf("%d", result.PRLifecycle.Merged)
|
||||
closedPRs = fmt.Sprintf("%d", result.PRLifecycle.ClosedOrRejected)
|
||||
}
|
||||
reviewedPRs, unreviewedPRs, needsReReviewPRs := "N/A", "N/A", "N/A"
|
||||
if result.PRReviewAudit != nil {
|
||||
reviewedPRs = fmt.Sprintf("%d", result.PRReviewAudit.Reviewed)
|
||||
unreviewedPRs = fmt.Sprintf("%d", result.PRReviewAudit.Unreviewed)
|
||||
needsReReviewPRs = fmt.Sprintf("%d", result.PRReviewAudit.NeedsReReview)
|
||||
}
|
||||
if _, err := fmt.Fprintln(tw, "REPOSITORY\tREPORT_SCORE\tRISK\tHEALTH_SCORE\tISSUES_ANALYZED\tHIGH_RISK_ISSUES\tPRS_ANALYZED\tHIGH_RISK_PRS\tOPEN_PRS\tMERGED_PRS\tCLOSED_PRS\tREVIEWED_PRS\tUNREVIEWED_PRS\tNEEDS_RE_REVIEW\tTOP_RECOMMENDATION"); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%d\t%d\t%d\t%d\t%s\n",
|
||||
if _, err := fmt.Fprintf(tw, "%s\t%d\t%s\t%s\t%d\t%d\t%d\t%d\t%s\t%s\t%s\t%s\t%s\t%s\t%s\n",
|
||||
result.Repository,
|
||||
result.ReportScore,
|
||||
result.RiskLevel,
|
||||
|
|
@ -176,6 +188,12 @@ func writeRepoReportTable(w io.Writer, result RepoReportResult, lang string) err
|
|||
result.IssueSummary.HighRisk,
|
||||
result.PRSummary.Total,
|
||||
result.PRSummary.HighRisk,
|
||||
openPRs,
|
||||
mergedPRs,
|
||||
closedPRs,
|
||||
reviewedPRs,
|
||||
unreviewedPRs,
|
||||
needsReReviewPRs,
|
||||
topRecommendation,
|
||||
); err != nil {
|
||||
return err
|
||||
|
|
@ -269,6 +287,36 @@ func writeRepoReportMarkdown(w io.Writer, result RepoReportResult, lang string)
|
|||
}
|
||||
writeCountMapMarkdown(w, "By type", result.PRSummary.ByType)
|
||||
writeCountMapMarkdown(w, "By risk", result.PRSummary.ByRisk)
|
||||
writeCountMapMarkdown(w, "Risk rule sources", result.PRSummary.RiskSources)
|
||||
if result.PRLifecycle != nil {
|
||||
if _, err := fmt.Fprintf(w, "- Lifecycle totals: open `%d`, merged `%d`, closed/rejected `%d`, all states `%d`\n",
|
||||
result.PRLifecycle.Open,
|
||||
result.PRLifecycle.Merged,
|
||||
result.PRLifecycle.ClosedOrRejected,
|
||||
result.PRLifecycle.Total,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if result.PRReviewAudit != nil {
|
||||
if _, err := fmt.Fprintf(w, "- Review audit: audited `%d`, reviewed `%d`, unreviewed `%d`, needs re-review `%d`, formal reviews `%d`\n",
|
||||
result.PRReviewAudit.Audited,
|
||||
result.PRReviewAudit.Reviewed,
|
||||
result.PRReviewAudit.Unreviewed,
|
||||
result.PRReviewAudit.NeedsReReview,
|
||||
result.PRReviewAudit.FormalReviews,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintf(w, "- Review actor attribution: reviewer comments `%d`, submitter comments `%d`, participant comments `%d`, system events `%d`\n",
|
||||
result.PRReviewAudit.ReviewerComments,
|
||||
result.PRReviewAudit.SubmitterComments,
|
||||
result.PRReviewAudit.ParticipantComments,
|
||||
result.PRReviewAudit.SystemEvents,
|
||||
); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if len(result.PRSummary.ReviewFocus) > 0 {
|
||||
if _, err := fmt.Fprintln(w, "- Review focus:"); err != nil {
|
||||
return err
|
||||
|
|
|
|||
|
|
@ -12,24 +12,76 @@ import (
|
|||
)
|
||||
|
||||
type RepoReportInput struct {
|
||||
Repository string `json:"repository"`
|
||||
Health *HealthInput `json:"health,omitempty"`
|
||||
Issues []IssueInput `json:"issues,omitempty"`
|
||||
PullRequests []PRSummaryInput `json:"pull_requests,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Repository string `json:"repository"`
|
||||
Health *HealthInput `json:"health,omitempty"`
|
||||
Issues []IssueInput `json:"issues,omitempty"`
|
||||
PullRequests []PRSummaryInput `json:"pull_requests,omitempty"`
|
||||
PRLifecycle *RepoPRLifecycle `json:"pr_lifecycle,omitempty"`
|
||||
PRReviewAudit *RepoPRReviewAudit `json:"pr_review_audit,omitempty"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type RepoReportResult struct {
|
||||
Repository string `json:"repository"`
|
||||
Health *HealthResult `json:"health,omitempty"`
|
||||
IssueSummary RepoIssueSummary `json:"issue_summary"`
|
||||
PRSummary RepoPRSummary `json:"pr_summary"`
|
||||
Recommendations []string `json:"recommendations"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ReportScore int `json:"report_score"`
|
||||
Sections []string `json:"sections"`
|
||||
Reasoning []string `json:"reasoning"`
|
||||
Source string `json:"source"`
|
||||
Repository string `json:"repository"`
|
||||
Health *HealthResult `json:"health,omitempty"`
|
||||
IssueSummary RepoIssueSummary `json:"issue_summary"`
|
||||
PRSummary RepoPRSummary `json:"pr_summary"`
|
||||
PRLifecycle *RepoPRLifecycle `json:"pr_lifecycle,omitempty"`
|
||||
PRReviewAudit *RepoPRReviewAudit `json:"pr_review_audit,omitempty"`
|
||||
Recommendations []string `json:"recommendations"`
|
||||
RiskLevel string `json:"risk_level"`
|
||||
ReportScore int `json:"report_score"`
|
||||
Sections []string `json:"sections"`
|
||||
Reasoning []string `json:"reasoning"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type RepoPRLifecycle struct {
|
||||
Open int `json:"open"`
|
||||
Merged int `json:"merged"`
|
||||
ClosedOrRejected int `json:"closed_or_rejected"`
|
||||
Total int `json:"total"`
|
||||
Source string `json:"source"`
|
||||
}
|
||||
|
||||
type RepoPRReviewAudit struct {
|
||||
Audited int `json:"audited"`
|
||||
Reviewed int `json:"reviewed"`
|
||||
Unreviewed int `json:"unreviewed"`
|
||||
NeedsReReview int `json:"needs_re_review"`
|
||||
FormalReviews int `json:"formal_reviews"`
|
||||
ReviewerComments int `json:"reviewer_comments"`
|
||||
SubmitterComments int `json:"submitter_comments"`
|
||||
ParticipantComments int `json:"participant_comments"`
|
||||
BotEvents int `json:"bot_events"`
|
||||
SystemEvents int `json:"system_events"`
|
||||
UnknownActorEvents int `json:"unknown_actor_events"`
|
||||
Errors int `json:"errors"`
|
||||
Source string `json:"source"`
|
||||
PullRequests []PRReviewAudit `json:"pull_requests,omitempty"`
|
||||
}
|
||||
|
||||
type PRReviewAudit struct {
|
||||
Number int `json:"number"`
|
||||
Author string `json:"author,omitempty"`
|
||||
IssueID int `json:"issue_id,omitempty"`
|
||||
Reviewed bool `json:"reviewed"`
|
||||
NeedsReReview bool `json:"needs_re_review"`
|
||||
ReviewStandard string `json:"review_standard"`
|
||||
FormalReviewStatus string `json:"formal_review_status"`
|
||||
FormalReviewCount int `json:"formal_review_count"`
|
||||
ReviewerComments int `json:"reviewer_comments"`
|
||||
SubmitterComments int `json:"submitter_comments"`
|
||||
ParticipantComments int `json:"participant_comments"`
|
||||
BotEvents int `json:"bot_events"`
|
||||
SystemEvents int `json:"system_events"`
|
||||
UnknownActorEvents int `json:"unknown_actor_events"`
|
||||
Reviewers []string `json:"reviewers,omitempty"`
|
||||
LatestReviewerAt string `json:"latest_reviewer_at,omitempty"`
|
||||
LatestSubmitterAt string `json:"latest_submitter_at,omitempty"`
|
||||
LatestCommitAt string `json:"latest_commit_at,omitempty"`
|
||||
LatestPRUpdateAt string `json:"latest_pr_update_at,omitempty"`
|
||||
Notes []string `json:"notes,omitempty"`
|
||||
}
|
||||
|
||||
type RepoIssueSummary struct {
|
||||
|
|
@ -44,6 +96,7 @@ type RepoPRSummary struct {
|
|||
Total int `json:"total"`
|
||||
ByType map[string]int `json:"by_type"`
|
||||
ByRisk map[string]int `json:"by_risk"`
|
||||
RiskSources map[string]int `json:"risk_sources,omitempty"`
|
||||
HighRisk int `json:"high_risk"`
|
||||
ReviewFocus []string `json:"review_focus"`
|
||||
}
|
||||
|
|
@ -54,11 +107,14 @@ func newRepoReportShortcut() *common.Shortcut {
|
|||
Description: "Generate a read-only repository workflow report",
|
||||
Flags: []common.Flag{
|
||||
{Name: "from", Usage: "Read repository report input from a JSON file"},
|
||||
{Name: "issue-limit", Usage: "Maximum issues to fetch and analyze", Default: "20"},
|
||||
{Name: "pr-limit", Usage: "Maximum pull requests to fetch and summarize", Default: "10"},
|
||||
{Name: "issue-limit", Usage: "Maximum issues to fetch and analyze; 0 analyzes all open issues", Default: "0"},
|
||||
{Name: "pr-limit", Usage: "Maximum pull requests to fetch and summarize; 0 analyzes all open pull requests", Default: "0"},
|
||||
{Name: "stale-days", Usage: "Days before an issue or PR is considered stale", Default: "30"},
|
||||
{Name: "include-issues", Usage: "Include issue triage summary", Bool: true, Default: "true"},
|
||||
{Name: "include-prs", Usage: "Include pull request summary", Bool: true, Default: "true"},
|
||||
{Name: "include-pr-lifecycle", Usage: "Include open/merged/closed PR totals", Bool: true, Default: "true"},
|
||||
{Name: "include-pr-review-audit", Usage: "Read formal reviews and PR conversation journals for actor attribution", Bool: true, Default: "false"},
|
||||
{Name: "pr-review-audit-limit", Usage: "Maximum analyzed PRs to review-audit; 0 audits every analyzed PR", Default: "0"},
|
||||
{Name: "include-health", Usage: "Include repository health summary", Bool: true, Default: "true"},
|
||||
{Name: "lang", Usage: "Output language: en or zh-CN", Default: langEN},
|
||||
},
|
||||
|
|
@ -104,11 +160,11 @@ func collectRepoReportInput(ctx *common.RuntimeContext) (RepoReportInput, []Scor
|
|||
return input, nil, nil
|
||||
}
|
||||
|
||||
issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 20, "issue-limit")
|
||||
issueLimit, err := parseIntArg(ctx.Arg("issue-limit"), 0, "issue-limit")
|
||||
if err != nil {
|
||||
return RepoReportInput{}, nil, err
|
||||
}
|
||||
prLimit, err := parseIntArg(ctx.Arg("pr-limit"), 10, "pr-limit")
|
||||
prLimit, err := parseIntArg(ctx.Arg("pr-limit"), 0, "pr-limit")
|
||||
if err != nil {
|
||||
return RepoReportInput{}, nil, err
|
||||
}
|
||||
|
|
@ -116,14 +172,21 @@ func collectRepoReportInput(ctx *common.RuntimeContext) (RepoReportInput, []Scor
|
|||
if err != nil {
|
||||
return RepoReportInput{}, nil, err
|
||||
}
|
||||
prReviewAuditLimit, err := parseIntArg(ctx.Arg("pr-review-audit-limit"), 0, "pr-review-audit-limit")
|
||||
if err != nil {
|
||||
return RepoReportInput{}, nil, err
|
||||
}
|
||||
|
||||
return FetchRepoReportInput(ctx, RepoReportFetchOptions{
|
||||
IssueLimit: issueLimit,
|
||||
PRLimit: prLimit,
|
||||
StaleDays: staleDays,
|
||||
IncludeIssues: parseBoolArg(ctx.Arg("include-issues")),
|
||||
IncludePRs: parseBoolArg(ctx.Arg("include-prs")),
|
||||
IncludeHealth: parseBoolArg(ctx.Arg("include-health")),
|
||||
IssueLimit: issueLimit,
|
||||
PRLimit: prLimit,
|
||||
StaleDays: staleDays,
|
||||
PRReviewAuditLimit: prReviewAuditLimit,
|
||||
IncludeIssues: parseBoolArg(ctx.Arg("include-issues")),
|
||||
IncludePRs: parseBoolArg(ctx.Arg("include-prs")),
|
||||
IncludePRLifecycle: parseBoolArg(ctx.Arg("include-pr-lifecycle")),
|
||||
IncludePRReviewAudit: parseBoolArg(ctx.Arg("include-pr-review-audit")),
|
||||
IncludeHealth: parseBoolArg(ctx.Arg("include-health")),
|
||||
})
|
||||
}
|
||||
|
||||
|
|
@ -198,6 +261,8 @@ func AnalyzeRepoReport(input RepoReportInput, lang string) RepoReportResult {
|
|||
Health: healthResult,
|
||||
IssueSummary: issueSummary,
|
||||
PRSummary: prSummary,
|
||||
PRLifecycle: input.PRLifecycle,
|
||||
PRReviewAudit: input.PRReviewAudit,
|
||||
Recommendations: recommendations,
|
||||
RiskLevel: risk,
|
||||
ReportScore: reportScore,
|
||||
|
|
@ -231,8 +296,9 @@ func summarizeRepoIssues(issues []IssueInput, lang string) (RepoIssueSummary, []
|
|||
|
||||
func summarizeRepoPRs(inputs []PRSummaryInput, lang string) (RepoPRSummary, []PRSummaryResult) {
|
||||
summary := RepoPRSummary{
|
||||
ByType: map[string]int{},
|
||||
ByRisk: map[string]int{},
|
||||
ByType: map[string]int{},
|
||||
ByRisk: map[string]int{},
|
||||
RiskSources: map[string]int{},
|
||||
}
|
||||
results := make([]PRSummaryResult, 0, len(inputs))
|
||||
focus := []string{}
|
||||
|
|
@ -244,6 +310,9 @@ func summarizeRepoPRs(inputs []PRSummaryInput, lang string) (RepoPRSummary, []PR
|
|||
summary.ByRisk[result.RiskLevel]++
|
||||
if result.RiskLevel == PRRiskHigh || result.RiskLevel == PRRiskCritical {
|
||||
summary.HighRisk++
|
||||
for _, reason := range result.RiskReasons {
|
||||
summary.RiskSources[reason]++
|
||||
}
|
||||
}
|
||||
focus = append(focus, result.ReviewFocus...)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,21 +2,23 @@ package workflow
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/gitlink-org/gitlink-cli/shortcuts/common"
|
||||
)
|
||||
|
||||
type RepoReportFetchOptions struct {
|
||||
Owner string
|
||||
Repo string
|
||||
IssueLimit int
|
||||
PRLimit int
|
||||
StaleDays int
|
||||
IncludeIssues bool
|
||||
IncludePRs bool
|
||||
IncludeHealth bool
|
||||
Owner string
|
||||
Repo string
|
||||
IssueLimit int
|
||||
PRLimit int
|
||||
StaleDays int
|
||||
PRReviewAuditLimit int
|
||||
IncludeIssues bool
|
||||
IncludePRs bool
|
||||
IncludePRLifecycle bool
|
||||
IncludePRReviewAudit bool
|
||||
IncludeHealth bool
|
||||
}
|
||||
|
||||
func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOptions) (RepoReportInput, []ScoringNote, error) {
|
||||
|
|
@ -24,12 +26,6 @@ func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOption
|
|||
if err != nil {
|
||||
return RepoReportInput{}, nil, fmt.Errorf("workflow +repo-report remote mode requires --owner and --repo or a Git remote: %w", err)
|
||||
}
|
||||
if opts.IssueLimit <= 0 {
|
||||
opts.IssueLimit = 20
|
||||
}
|
||||
if opts.PRLimit <= 0 {
|
||||
opts.PRLimit = 10
|
||||
}
|
||||
if opts.StaleDays <= 0 {
|
||||
opts.StaleDays = 30
|
||||
}
|
||||
|
|
@ -60,13 +56,7 @@ func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOption
|
|||
}
|
||||
|
||||
if opts.IncludeIssues {
|
||||
issues, err := FetchIssuesForTriage(ctx, TriageFetchOptions{
|
||||
Owner: owner,
|
||||
Repo: repo,
|
||||
State: "open",
|
||||
Limit: opts.IssueLimit,
|
||||
Page: 1,
|
||||
})
|
||||
issues, err := fetchIssueListForReport(ctx, owner, repo, opts.IssueLimit)
|
||||
if err != nil {
|
||||
notes = append(notes, ScoringNote{Metric: "repo_report_issues", Note: fmt.Sprintf("issue fetch failed: %v", err)})
|
||||
} else {
|
||||
|
|
@ -82,6 +72,16 @@ func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOption
|
|||
} else {
|
||||
input.PullRequests = prs
|
||||
successes++
|
||||
if opts.IncludePRLifecycle {
|
||||
lifecycle, lifecycleNotes := fetchPRLifecycle(ctx, owner, repo)
|
||||
input.PRLifecycle = lifecycle
|
||||
notes = append(notes, lifecycleNotes...)
|
||||
}
|
||||
if opts.IncludePRReviewAudit {
|
||||
audit, auditNotes := fetchPRReviewAudit(ctx, owner, repo, prs, opts.PRReviewAuditLimit)
|
||||
input.PRReviewAudit = audit
|
||||
notes = append(notes, auditNotes...)
|
||||
}
|
||||
if len(prs) > 0 {
|
||||
notes = append(notes, ScoringNote{
|
||||
Metric: "repo_report_prs",
|
||||
|
|
@ -100,26 +100,65 @@ func FetchRepoReportInput(ctx *common.RuntimeContext, opts RepoReportFetchOption
|
|||
return input, uniqueScoringNotes(notes), nil
|
||||
}
|
||||
|
||||
func fetchPRListForReport(ctx *common.RuntimeContext, owner, repo string, limit int) ([]PRSummaryInput, error) {
|
||||
if limit <= 0 {
|
||||
limit = 10
|
||||
func fetchPRLifecycle(ctx *common.RuntimeContext, owner, repo string) (*RepoPRLifecycle, []ScoringNote) {
|
||||
states := []struct {
|
||||
name string
|
||||
value *int
|
||||
}{
|
||||
{name: "open"},
|
||||
{name: "merged"},
|
||||
{name: "closed"},
|
||||
}
|
||||
query := url.Values{}
|
||||
query.Set("state", "open")
|
||||
query.Set("page", "1")
|
||||
query.Set("limit", fmt.Sprintf("%d", limit))
|
||||
lifecycle := &RepoPRLifecycle{Source: "remote-read-only-fetch:list-totals"}
|
||||
states[0].value = &lifecycle.Open
|
||||
states[1].value = &lifecycle.Merged
|
||||
states[2].value = &lifecycle.ClosedOrRejected
|
||||
notes := []ScoringNote{}
|
||||
successes := 0
|
||||
for _, state := range states {
|
||||
total, err := fetchPRStateTotal(ctx, owner, repo, state.name)
|
||||
if err != nil {
|
||||
notes = append(notes, ScoringNote{
|
||||
Metric: "repo_report_pr_lifecycle",
|
||||
Note: fmt.Sprintf("%s PR total unavailable: %v", state.name, err),
|
||||
})
|
||||
continue
|
||||
}
|
||||
*state.value = total
|
||||
successes++
|
||||
}
|
||||
if successes == 0 {
|
||||
return nil, notes
|
||||
}
|
||||
lifecycle.Total = lifecycle.Open + lifecycle.Merged + lifecycle.ClosedOrRejected
|
||||
return lifecycle, notes
|
||||
}
|
||||
|
||||
func fetchPRStateTotal(ctx *common.RuntimeContext, owner, repo, state string) (int, error) {
|
||||
query := pullListQuery(state)
|
||||
query.Set("page", "1")
|
||||
query.Set("limit", "1")
|
||||
env, err := ctx.CallAPIWithQuery("GET", workflowRepoPath(owner, repo)+"/pulls", query)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if env.Meta != nil && env.Meta.TotalCount > 0 {
|
||||
return env.Meta.TotalCount, nil
|
||||
}
|
||||
if total := apiListTotal(env.Data); total > 0 {
|
||||
return total, nil
|
||||
}
|
||||
return len(apiList(env.Data)), nil
|
||||
}
|
||||
|
||||
func fetchPRListForReport(ctx *common.RuntimeContext, owner, repo string, limit int) ([]PRSummaryInput, error) {
|
||||
pageSize := reportPageSize(limit)
|
||||
items, err := fetchListItems(ctx, workflowRepoPath(owner, repo)+"/pulls", pullListQuery("open"), pageSize, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items := apiList(env.Data)
|
||||
inputs := make([]PRSummaryInput, 0, len(items))
|
||||
for _, raw := range items {
|
||||
item, ok := raw.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, item := range items {
|
||||
input, ok := normalizePRSummaryItem(item)
|
||||
if !ok {
|
||||
continue
|
||||
|
|
@ -130,9 +169,37 @@ func fetchPRListForReport(ctx *common.RuntimeContext, owner, repo string, limit
|
|||
input.State = "open"
|
||||
}
|
||||
inputs = append(inputs, input)
|
||||
if len(inputs) >= limit {
|
||||
if limit > 0 && len(inputs) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return inputs, nil
|
||||
}
|
||||
|
||||
func fetchIssueListForReport(ctx *common.RuntimeContext, owner, repo string, limit int) ([]IssueInput, error) {
|
||||
pageSize := reportPageSize(limit)
|
||||
items, err := fetchListItems(ctx, workflowRepoPath(owner, repo)+"/issues", issueListQuery("open"), pageSize, limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
issues := make([]IssueInput, 0, len(items))
|
||||
for _, item := range items {
|
||||
issue, ok := normalizeIssueItem(item)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
issues = append(issues, issue)
|
||||
if limit > 0 && len(issues) >= limit {
|
||||
break
|
||||
}
|
||||
}
|
||||
return issues, nil
|
||||
}
|
||||
|
||||
func reportPageSize(limit int) int {
|
||||
const maxPageSize = 50
|
||||
if limit > 0 && limit < maxPageSize {
|
||||
return limit
|
||||
}
|
||||
return maxPageSize
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package workflow
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
|
|
@ -173,6 +174,210 @@ func TestFetchRepoReportInputPRListMetadata(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFetchRepoReportInputPaginatesAllOpenItemsByDefault(t *testing.T) {
|
||||
prPages := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/v1/owner/repo/issues.json":
|
||||
if got := r.URL.Query().Get("category"); got != "opened" {
|
||||
t.Fatalf("issue category = %q, want opened", got)
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"total_count": 2,
|
||||
"issues": []map[string]interface{}{
|
||||
{"id": 1, "number": 1, "title": "First open issue"},
|
||||
{"id": 2, "number": 2, "title": "Second open issue"},
|
||||
},
|
||||
})
|
||||
case "/v1/owner/repo/pulls.json":
|
||||
if got := r.URL.Query().Get("status"); got != "0" {
|
||||
t.Fatalf("PR status = %q, want 0", got)
|
||||
}
|
||||
page := mustParseInt(r.URL.Query().Get("page"), 1)
|
||||
prPages++
|
||||
start := (page - 1) * 50
|
||||
end := start + 50
|
||||
if end > 120 {
|
||||
end = 120
|
||||
}
|
||||
pulls := make([]map[string]interface{}, 0, end-start)
|
||||
for index := start + 1; index <= end; index++ {
|
||||
pulls = append(pulls, map[string]interface{}{
|
||||
"id": 1000 + index,
|
||||
"index": index,
|
||||
"title": fmt.Sprintf("PR %d", index),
|
||||
})
|
||||
}
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"total_count": 120,
|
||||
"pulls": pulls,
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, _, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
|
||||
IncludeHealth: false,
|
||||
IncludeIssues: true,
|
||||
IncludePRs: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if len(input.Issues) != 2 {
|
||||
t.Fatalf("len(Issues) = %d, want 2", len(input.Issues))
|
||||
}
|
||||
if len(input.PullRequests) != 120 {
|
||||
t.Fatalf("len(PullRequests) = %d, want 120", len(input.PullRequests))
|
||||
}
|
||||
if prPages != 3 {
|
||||
t.Fatalf("PR pages = %d, want 3", prPages)
|
||||
}
|
||||
if input.PullRequests[119].Number != 120 {
|
||||
t.Fatalf("last PR number = %d, want 120", input.PullRequests[119].Number)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRepoReportInputIncludesPRLifecycleTotals(t *testing.T) {
|
||||
openCalls := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/pulls.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "1" {
|
||||
t.Fatalf("PR limit = %q, want 1", got)
|
||||
}
|
||||
switch r.URL.Query().Get("status") {
|
||||
case "0":
|
||||
openCalls++
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"total_count": 12,
|
||||
"pulls": []map[string]interface{}{
|
||||
{"id": 1001, "index": 1, "title": "Open PR"},
|
||||
},
|
||||
})
|
||||
case "1":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"total_count": 5,
|
||||
"pulls": []map[string]interface{}{},
|
||||
})
|
||||
case "2":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"total_count": 7,
|
||||
"pulls": []map[string]interface{}{},
|
||||
})
|
||||
default:
|
||||
t.Fatalf("unexpected PR status query: %q", r.URL.Query().Get("status"))
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
|
||||
PRLimit: 1,
|
||||
IncludeHealth: false,
|
||||
IncludeIssues: false,
|
||||
IncludePRs: true,
|
||||
IncludePRLifecycle: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if len(input.PullRequests) != 1 {
|
||||
t.Fatalf("len(PullRequests) = %d, want 1", len(input.PullRequests))
|
||||
}
|
||||
if input.PRLifecycle == nil {
|
||||
t.Fatal("PRLifecycle is nil, want totals")
|
||||
}
|
||||
if input.PRLifecycle.Open != 12 || input.PRLifecycle.Merged != 5 || input.PRLifecycle.ClosedOrRejected != 7 || input.PRLifecycle.Total != 24 {
|
||||
t.Fatalf("PRLifecycle = %+v, want open=12 merged=5 closed=7 total=24", input.PRLifecycle)
|
||||
}
|
||||
if openCalls != 2 {
|
||||
t.Fatalf("open status calls = %d, want 2 for list fetch plus lifecycle total", openCalls)
|
||||
}
|
||||
if !hasNote(notes, "repo_report_prs") {
|
||||
t.Fatalf("notes = %+v, want repo_report_prs note", notes)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchRepoReportInputIncludesPRReviewAudit(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch {
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{
|
||||
"total_count": 2,
|
||||
"pulls": []map[string]interface{}{
|
||||
{
|
||||
"id": 1001,
|
||||
"index": 1,
|
||||
"title": "feat: reviewed change",
|
||||
"author": map[string]interface{}{"login": "alice"},
|
||||
"issue": map[string]interface{}{"id": 501},
|
||||
"updated_at": "2026-06-02T12:00:00Z",
|
||||
},
|
||||
{"id": 1002, "index": 2, "title": "docs: unreviewed change", "author": map[string]interface{}{"login": "dana"}, "issue": map[string]interface{}{"id": 502}},
|
||||
},
|
||||
})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/1/reviews.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"reviews": []map[string]interface{}{
|
||||
{"reviewer": map[string]interface{}{"login": "bob"}, "status": "approved", "content": "looks good", "created_at": "2026-06-01T10:00:00Z"},
|
||||
}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/pulls/2/reviews.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"reviews": []map[string]interface{}{}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/501/journals.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"journals": []map[string]interface{}{
|
||||
{"user": map[string]interface{}{"login": "alice"}, "notes": "I updated the branch", "operate_category": "comment", "created_at": "2026-06-02T11:00:00Z"},
|
||||
{"user": map[string]interface{}{"login": "bob"}, "notes": "Please keep this test", "operate_category": "comment", "created_at": "2026-06-01T09:30:00Z"},
|
||||
{"user": map[string]interface{}{"login": "carol"}, "notes": "I can reproduce this", "operate_category": "comment", "created_at": "2026-06-01T12:00:00Z"},
|
||||
{"user": map[string]interface{}{"login": "system"}, "operate_content": "status changed", "operate_category": "status"},
|
||||
}})
|
||||
case r.Method == "GET" && r.URL.Path == "/v1/owner/repo/issues/502/journals.json":
|
||||
writeWorkflowJSON(t, w, map[string]interface{}{"journals": []map[string]interface{}{
|
||||
{"user": map[string]interface{}{"login": "dana"}, "notes": "Initial description", "operate_category": "comment"},
|
||||
}})
|
||||
default:
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
input, notes, err := FetchRepoReportInput(workflowTestContext(server), RepoReportFetchOptions{
|
||||
IncludeHealth: false,
|
||||
IncludeIssues: false,
|
||||
IncludePRs: true,
|
||||
IncludePRReviewAudit: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("FetchRepoReportInput returned error: %v", err)
|
||||
}
|
||||
if len(notes) != 1 || notes[0].Metric != "repo_report_prs" {
|
||||
t.Fatalf("notes = %+v, want only list metadata note", notes)
|
||||
}
|
||||
audit := input.PRReviewAudit
|
||||
if audit == nil {
|
||||
t.Fatal("PRReviewAudit is nil")
|
||||
}
|
||||
if audit.Audited != 2 || audit.Reviewed != 1 || audit.Unreviewed != 1 || audit.NeedsReReview != 1 || audit.FormalReviews != 1 {
|
||||
t.Fatalf("audit summary = %+v, want audited=2 reviewed=1 unreviewed=1 formal=1", audit)
|
||||
}
|
||||
if audit.SubmitterComments != 2 || audit.ReviewerComments != 1 || audit.ParticipantComments != 1 || audit.SystemEvents != 1 {
|
||||
t.Fatalf("actor counts = submitter:%d reviewer:%d participant:%d system:%d",
|
||||
audit.SubmitterComments, audit.ReviewerComments, audit.ParticipantComments, audit.SystemEvents)
|
||||
}
|
||||
first := audit.PullRequests[0]
|
||||
if !first.Reviewed || first.ReviewStandard != reviewStandardFormal || first.FormalReviewStatus != "approved" {
|
||||
t.Fatalf("first audit = %+v, want formal approved review", first)
|
||||
}
|
||||
if !first.NeedsReReview {
|
||||
t.Fatalf("first audit = %+v, want needs_re_review after submitter update", first)
|
||||
}
|
||||
second := audit.PullRequests[1]
|
||||
if second.Reviewed || second.ReviewStandard != reviewStandardUnreviewed {
|
||||
t.Fatalf("second audit = %+v, want unreviewed despite submitter comment", second)
|
||||
}
|
||||
}
|
||||
|
||||
func hasNote(notes []ScoringNote, metric string) bool {
|
||||
for _, note := range notes {
|
||||
if note.Metric == metric {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,23 @@ func TestAnalyzeRepoReportPartialInput(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRepoReportAggregatesPRRiskSources(t *testing.T) {
|
||||
result := AnalyzeRepoReport(RepoReportInput{
|
||||
Repository: "owner/repo",
|
||||
PullRequests: []PRSummaryInput{{
|
||||
Number: 1,
|
||||
Title: "fix: prevent secret token leak",
|
||||
Source: "remote-read-only-fetch:list-metadata",
|
||||
}},
|
||||
}, "en")
|
||||
if result.PRSummary.HighRisk != 1 {
|
||||
t.Fatalf("HighRisk = %d, want 1", result.PRSummary.HighRisk)
|
||||
}
|
||||
if result.PRSummary.RiskSources["security-sensitive keyword"] != 1 {
|
||||
t.Fatalf("RiskSources = %v, want security-sensitive keyword=1", result.PRSummary.RiskSources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeRepoReportChinese(t *testing.T) {
|
||||
result := AnalyzeRepoReport(sampleRepoReportInput(), "zh-CN")
|
||||
if len(result.Recommendations) == 0 {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ package workflow
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
|
|
@ -28,8 +27,7 @@ func FetchIssuesForTriage(ctx *common.RuntimeContext, opts TriageFetchOptions) (
|
|||
state = "open"
|
||||
}
|
||||
|
||||
query := url.Values{}
|
||||
query.Set("state", state)
|
||||
query := issueListQuery(state)
|
||||
query.Set("limit", fmt.Sprintf("%d", limit))
|
||||
query.Set("page", fmt.Sprintf("%d", page))
|
||||
if len(opts.Labels) > 0 {
|
||||
|
|
|
|||
|
|
@ -16,8 +16,11 @@ func TestFetchIssuesForTriageNormalizesAPIResponse(t *testing.T) {
|
|||
if r.Method != "GET" || r.URL.Path != "/v1/owner/repo/issues.json" {
|
||||
t.Fatalf("unexpected request: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if got := r.URL.Query().Get("state"); got != "open" {
|
||||
t.Fatalf("state query = %q, want open", got)
|
||||
if got := r.URL.Query().Get("category"); got != "opened" {
|
||||
t.Fatalf("category query = %q, want opened", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("state"); got != "" {
|
||||
t.Fatalf("state query = %q, want empty", got)
|
||||
}
|
||||
if got := r.URL.Query().Get("limit"); got != "30" {
|
||||
t.Fatalf("limit query = %q, want 30", got)
|
||||
|
|
|
|||
Loading…
Reference in New Issue