Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 27 additions & 24 deletions gittensor/classes.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,20 @@
from gittensor.validator.oss_contributions.mirror.scored_pr import ScoredMirrorPR

from gittensor.constants import (
LABEL_MULTIPLIERS,
MAINTAINER_ASSOCIATIONS,
MAX_CODE_DENSITY_MULTIPLIER,
MIN_TOKEN_SCORE_FOR_BASE_SCORE,
)
from gittensor.utils.utils import parse_repo_name

GITHUB_DOMAIN = 'https://github.com/'

def _apply_score_multipliers(base_score: float, multipliers: Dict[str, float], pr_label: str) -> float:
"""Compute earned score and emit the standard scoring log lines."""
earned = base_score * prod(multipliers.values())
mult_str = ' × '.join(f'{k}={v:.2f}' for k, v in multipliers.items())
bt.logging.info(f'├─ {pr_label} → {earned:.2f}')
bt.logging.info(f'│ └─ {base_score:.2f} × {mult_str}')
return earned


class PRState(Enum):
Expand Down Expand Up @@ -183,8 +189,10 @@ class PullRequest:
time_decay_multiplier: float = 1.0
credibility_multiplier: float = 1.0
review_quality_multiplier: float = 1.0 # Penalty for CHANGES_REQUESTED reviews from maintainers
label_multiplier: float = 1.0 # Multiplier based on PR label (exact match against known labels)
label: Optional[str] = None # Last label set on the PR
label_multiplier: float = 1.0 # Multiplier resolved from repository label config
label: Optional[str] = None # Resolved scoring label, set during scoring
current_labels: frozenset[str] = field(default_factory=frozenset)
label_timeline_order: tuple[str, ...] = field(default_factory=tuple) # Newest current labels first
changes_requested_count: int = 0 # Number of maintainer CHANGES_REQUESTED reviews
earned_score: float = 0.0
collateral_score: float = 0.0 # For OPEN PRs: potential_score * collateral_percent
Expand Down Expand Up @@ -232,15 +240,8 @@ def calculate_final_earned_score(self) -> float:
'cred': self.credibility_multiplier,
'review': self.review_quality_multiplier,
}

self.earned_score = self.base_score * prod(multipliers.values())

mult_str = ' × '.join(f'{k}={v:.2f}' for k, v in multipliers.items())
bt.logging.info(
f'├─ {self.pr_state.value} PR #{self.number} ({self.repository_full_name}) → {self.earned_score:.2f}'
)
bt.logging.info(f'│ └─ {self.base_score:.2f} × {mult_str}')

label = f'{self.pr_state.value} PR #{self.number} ({self.repository_full_name})'
self.earned_score = _apply_score_multipliers(self.base_score, multipliers, label)
return self.earned_score

@classmethod
Expand Down Expand Up @@ -310,18 +311,19 @@ def from_graphql_response(cls, pr_data: dict, uid: int, hotkey: str, github_id:
cr_reviews = (pr_data.get('changesRequestedReviews') or {}).get('nodes') or []
changes_requested_count = sum(1 for r in cr_reviews if r.get('authorAssociation') in MAINTAINER_ASSOCIATIONS)

current = {(n.get('name') or '').lower() for n in (pr_data.get('labels') or {}).get('nodes') or [] if n}
label: Optional[str] = None
scoring_labels = current & LABEL_MULTIPLIERS.keys()
if scoring_labels:
current = frozenset(
(n.get('name') or '').lower()
for n in (pr_data.get('labels') or {}).get('nodes') or []
if n and n.get('name')
)
timeline_ordered: list[str] = []
if current:
seen: set[str] = set()
for event in reversed((pr_data.get('timelineItems') or {}).get('nodes') or []):
name = ((event or {}).get('label') or {}).get('name', '').lower()
if name in scoring_labels:
label = name
break
if label is None:
# Timeline truncated — fall back to highest-multiplier currently-applied label
label = max(scoring_labels, key=lambda n: (LABEL_MULTIPLIERS[n], n))
if name and name in current and name not in seen:
seen.add(name)
timeline_ordered.append(name)

return cls(
number=pr_data['number'],
Expand All @@ -343,7 +345,8 @@ def from_graphql_response(cls, pr_data: dict, uid: int, hotkey: str, github_id:
last_edited_at=last_edited_at,
head_ref_oid=pr_data.get('headRefOid'),
base_ref_oid=pr_data.get('baseRefOid'),
label=label,
current_labels=current,
label_timeline_order=tuple(timeline_ordered),
changes_requested_count=changes_requested_count,
)

Expand Down
17 changes: 6 additions & 11 deletions gittensor/cli/issue_commands/helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,9 @@
from rich.console import Console

from gittensor.cli.issue_commands.tables import build_pr_table
from gittensor.constants import NETWORK_MAP
from gittensor.constants import BASE_GITHUB_API_URL, MAX_ISSUE_ID, NETWORK_MAP
from gittensor.validator.issue_competitions.storage_utils import (
ISSUES_MAPPING_ROOT_KEY,
compute_ink5_lazy_key,
decode_issue_from_storage,
decode_packed_contract_storage,
Expand All @@ -38,7 +39,6 @@
ALPHA_RAW_UNIT = 10**ALPHA_DECIMALS
MIN_BOUNTY_ALPHA = 10
MAX_BOUNTY_ALPHA = 100_000_000
MAX_ISSUE_ID = 1_000_000
MAX_ISSUE_NUMBER = 2**32 - 1
REPO_PATTERN = re.compile(r'^[a-zA-Z0-9._-]+/[a-zA-Z0-9._-]+$')
GITHUB_API_TIMEOUT = 10
Expand Down Expand Up @@ -268,18 +268,13 @@ def confirm_or_abort(prompt: str, yes: bool, default: bool = False) -> bool:
return False


def get_github_pat() -> Optional[str]:
"""Return GITTENSOR_MINER_PAT from environment, or None."""
return os.environ.get('GITTENSOR_MINER_PAT') or None


def fetch_open_issue_pull_requests(
repository_full_name: str,
issue_number: int,
as_json: bool,
) -> list:
"""Fetch open PR submissions for a GitHub issue."""
token = get_github_pat() or ''
token = os.environ.get('GITTENSOR_MINER_PAT') or ''
if not token and not as_json:
print_warning('No GitHub token found; set GITTENSOR_MINER_PAT to fetch GitHub issue submissions')

Expand Down Expand Up @@ -409,7 +404,7 @@ def validate_repository(
if verify_exists:
try:
resp = requests.get(
f'https://api.github.com/repos/{owner}/{repo_name}',
f'{BASE_GITHUB_API_URL}/repos/{owner}/{repo_name}',
headers={'User-Agent': 'gittensor-cli'},
timeout=GITHUB_API_TIMEOUT,
)
Expand Down Expand Up @@ -457,7 +452,7 @@ def validate_github_issue(
"""
try:
resp = requests.get(
f'https://api.github.com/repos/{owner}/{repo}/issues/{issue_number}',
f'{BASE_GITHUB_API_URL}/repos/{owner}/{repo}/issues/{issue_number}',
headers={'User-Agent': 'gittensor-cli'},
timeout=GITHUB_API_TIMEOUT,
)
Expand Down Expand Up @@ -810,7 +805,7 @@ def _read_issues_from_child_storage(substrate, contract_addr: str, verbose: bool
for issue_id in range(1, next_issue_id):
# SCALE encode u64 as little-endian 8 bytes
encoded_id = struct.pack('<Q', issue_id)
lazy_key = compute_ink5_lazy_key('52789899', encoded_id)
lazy_key = compute_ink5_lazy_key(ISSUES_MAPPING_ROOT_KEY, encoded_id)

val_result = substrate.rpc_request('childstate_getStorage', [child_key, lazy_key, None])
if not val_result.get('result'):
Expand Down
29 changes: 27 additions & 2 deletions gittensor/cli/issue_commands/view.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,11 +30,24 @@
print_network_header,
read_issues_from_contract,
validate_issue_id,
validate_repository,
with_cli_behavior_options,
with_network_contract_options,
)


def _fill_percent(bounty: int, target: int) -> float:
"""Compute bounty fill percentage with Decimal precision.

Returns 0.0 when target is non-positive, matching the existing fallback.
Both render paths (Panel single-issue view and table all-issues view) call
this so the same on-chain values render identically regardless of mode.
"""
if target <= 0:
return 0.0
return float(Decimal(bounty) / Decimal(target) * 100)


@click.command('list', cls=StyledCommand)
@click.option(
'--id',
Expand Down Expand Up @@ -74,6 +87,18 @@ def issues_list(
except click.BadParameter as e:
handle_exception(as_json, str(e), 'bad_parameter')

# Normalize and validate the --repo filter before any contract reads so
# malformed input is rejected up-front and whitespace-padded valid input
# still matches `repository_full_name` from the contract. validate_repository
# strips whitespace, enforces owner/name format, and raises
# click.BadParameter on bad input — same contract used by mutating commands.
if repo_filter is not None:
try:
owner, repo_name = validate_repository(repo_filter, verify_exists=False)
repo_filter = f'{owner}/{repo_name}'
except click.BadParameter as e:
handle_exception(as_json, str(e), 'bad_parameter')

contract_addr, ws_endpoint, network_name = _resolve_contract_and_network(
contract,
network,
Expand Down Expand Up @@ -113,7 +138,7 @@ def issues_list(
if issue:
bounty_raw = issue.get('bounty_amount', 0)
target_raw = issue.get('target_bounty', 0)
fill_pct = (bounty_raw / target_raw * 100) if target_raw > 0 else 0
fill_pct = _fill_percent(bounty_raw, target_raw)
console.print(
Panel(
f'[cyan]ID:[/cyan] {issue["id"]}\n'
Expand Down Expand Up @@ -168,7 +193,7 @@ def issues_list(
target_str = format_alpha(target_val, 1) if target_val else '0.0'

if target_val > 0:
fill_pct = float(Decimal(bounty_val) / Decimal(target_val) * 100)
fill_pct = _fill_percent(bounty_val, target_val)
if fill_pct >= 100:
bounty_display = f'{bounty_str} (100%)'
elif bounty_val > 0:
Expand Down
29 changes: 1 addition & 28 deletions gittensor/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,29 +83,6 @@
# Boosts
MAX_CODE_DENSITY_MULTIPLIER = 1.15

# Label multipliers - applied based on the last label set on the PR (requires triage+ access)
LABEL_MULTIPLIERS: dict[str, float] = {
# features
'feature': 1.50,
'feat': 1.50,
# bug fixes
'bug': 1.25,
'fix': 1.25,
'crash': 1.25,
'regression': 1.25,
'security': 1.25,
# enhancements
'enhancement': 1.10,
'improve': 1.10,
'perf': 1.10,
# refactors
'refactor': 0.5,
'cleanup': 0.5,
'polish': 0.5,
'debt': 0.5,
'chore': 0.5,
}

# Pioneer dividend — rewards the first quality contributor to each repository
# Rates applied per follower position (1st follower pays most, diminishing after)
# Dividend capped at PIONEER_DIVIDEND_MAX_RATIO × pioneer's own earned_score
Expand Down Expand Up @@ -166,11 +143,6 @@
OPEN_ISSUE_SPAM_TOKEN_SCORE_PER_SLOT = 300.0 # +1 allowed open issue per this much token score
MAX_OPEN_ISSUE_THRESHOLD = 30

# Repo-centric closed issue scan caps (validator PAT budget)
REPO_SCAN_PER_REPO_CAP = 300 # max solver lookups per repo
REPO_SCAN_GLOBAL_CAP = 1500 # max solver lookups per round
REPO_SCAN_CONCURRENCY = 2 # concurrent solver lookup threads

# =============================================================================
# Collateral
# =============================================================================
Expand Down Expand Up @@ -215,3 +187,4 @@
CONTRACT_ADDRESS = '5FWNdk8YNtNcHKrAx2krqenFrFAZG7vmsd2XN2isJSew3MrD'
ISSUES_TREASURY_UID = 111 # UID of the smart contract neuron, if set to RECYCLE_UID then it's disabled
ISSUES_TREASURY_EMISSION_SHARE = 0.15 # % of emissions allocated to funding issues treasury
MAX_ISSUE_ID = 1_000_000 # sanity-check upper bound for any real deployment
2 changes: 1 addition & 1 deletion gittensor/utils/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ def check_config(cls, config: Any):
config.neuron.name,
)
)
print('full path:', full_path)
bt.logging.debug(f'Neuron full path: {full_path}')
config.neuron.full_path = os.path.expanduser(full_path)
if not os.path.exists(config.neuron.full_path):
os.makedirs(config.neuron.full_path, exist_ok=True)
Expand Down
Loading